diff --git a/CHANGELOG.md b/CHANGELOG.md
index e0dba0a..1f8e422 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,19 @@ All notable changes to the netrock generator will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/).
+## [0.10.0] - 2026-08-17
+
+### Added
+
+- Machine-readable error codes on every ProblemDetails response: `ErrorMessages` entries are now `Error` records (stable snake_case `code` + message), `Result.Failure()` takes an `Error`, and `ProblemFactory` writes the code to the `code` extension (framework-generated bodies get `validation_failed` or a snake_case reason phrase such as `not_found`). New `ProblemDetailsSchemaTransformer` documents the extension in OpenAPI, and `ErrorMessagesTests` enforce code naming and uniqueness
+- Hangfire-backed email delivery: with both `email` and `jobs` enabled, transactional emails are queued as `EmailDeliveryJob` via `BackgroundEmailService` and retried automatically (5 attempts, 30s to 1h backoff) instead of being lost on SMTP failure. Without `jobs`, `SmtpEmailService` still sends inline
+- New API tests (`ProblemFactoryTests`, `ProblemDetailsCodeTests`, `ProblemDetailsAssert` fixture) and component tests for the email job pipeline
+- Frontend: `getErrorCode()` and `getErrorMessage(error, fallback, messagesByCode)` translate by backend code; login form and OAuth callback page map on codes instead of English `detail` text
+
+### Fixed
+
+- Frontend API proxy buffers request bodies so backend 401 responses pass through instead of surfacing as 502 on Node 24 (undici streaming issue)
+
## [0.9.5] - 2026-08-17
### Changed
diff --git a/packages/core/package.json b/packages/core/package.json
index 158929d..13548b4 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -1,6 +1,6 @@
{
"name": "@netrock/core",
- "version": "0.9.5",
+ "version": "0.10.0",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
diff --git a/packages/core/src/manifests/auth.ts b/packages/core/src/manifests/auth.ts
index 2689d00..ea3f3c1 100644
--- a/packages/core/src/manifests/auth.ts
+++ b/packages/core/src/manifests/auth.ts
@@ -438,6 +438,14 @@ export function registerAuthManifest(): void {
path: 'src/backend/tests/MyProject.Api.Tests/Fixtures/TestAuthHandler.cs',
templated: false
},
+ {
+ path: 'src/backend/tests/MyProject.Api.Tests/Fixtures/ProblemDetailsAssert.cs',
+ templated: false
+ },
+ {
+ path: 'src/backend/tests/MyProject.Api.Tests/Shared/ProblemFactoryTests.cs',
+ templated: false
+ },
{
path: 'src/backend/tests/MyProject.Api.Tests/Contracts/ResponseContracts.cs',
templated: false
@@ -446,6 +454,10 @@ export function registerAuthManifest(): void {
path: 'src/backend/tests/MyProject.Api.Tests/Middlewares/OriginValidationMiddlewareTests.cs',
templated: false
},
+ {
+ path: 'src/backend/tests/MyProject.Api.Tests/Middlewares/ProblemDetailsCodeTests.cs',
+ templated: true
+ },
{
path: 'src/backend/tests/MyProject.Api.Tests/Validation/CorsOptionsValidationTests.cs',
templated: false
diff --git a/packages/core/src/manifests/core.ts b/packages/core/src/manifests/core.ts
index c531397..72251d7 100644
--- a/packages/core/src/manifests/core.ts
+++ b/packages/core/src/manifests/core.ts
@@ -15,6 +15,7 @@ export function registerCoreManifest(): void {
{ path: 'src/backend/MyProject.Domain/MyProject.Domain.csproj', templated: false },
// Shared
+ { path: 'src/backend/MyProject.Shared/Error.cs', templated: false },
{ path: 'src/backend/MyProject.Shared/ErrorMessages.cs', templated: true },
{ path: 'src/backend/MyProject.Shared/ErrorType.cs', templated: false },
{ path: 'src/backend/MyProject.Shared/MyProject.Shared.csproj', templated: false },
@@ -121,6 +122,10 @@ export function registerCoreManifest(): void {
path: 'src/backend/MyProject.WebApi/Features/OpenApi/Transformers/EnumSchemaTransformer.cs',
templated: false
},
+ {
+ path: 'src/backend/MyProject.WebApi/Features/OpenApi/Transformers/ProblemDetailsSchemaTransformer.cs',
+ templated: false
+ },
{
path: 'src/backend/MyProject.WebApi/Features/OpenApi/Transformers/NumericSchemaTransformer.cs',
templated: false
diff --git a/packages/core/src/manifests/email.ts b/packages/core/src/manifests/email.ts
index 701edc8..384f9bb 100644
--- a/packages/core/src/manifests/email.ts
+++ b/packages/core/src/manifests/email.ts
@@ -26,12 +26,20 @@ export function registerEmailManifest(): void {
// Infrastructure - Email services and configuration
{
path: 'src/backend/MyProject.Infrastructure/Features/Email/Extensions/ServiceCollectionExtensions.cs',
- templated: false
+ templated: true
+ },
+ {
+ path: 'src/backend/MyProject.Infrastructure/Features/Email/Jobs/EmailDeliveryJob.cs',
+ templated: true
},
{
path: 'src/backend/MyProject.Infrastructure/Features/Email/Options/EmailOptions.cs',
templated: false
},
+ {
+ path: 'src/backend/MyProject.Infrastructure/Features/Email/Services/BackgroundEmailService.cs',
+ templated: true
+ },
{
path: 'src/backend/MyProject.Infrastructure/Features/Email/Services/FluidEmailTemplateRenderer.cs',
templated: true
@@ -56,6 +64,18 @@ export function registerEmailManifest(): void {
},
// Tests - Email services
+ {
+ path: 'src/backend/tests/MyProject.Component.Tests/Extensions/EmailServiceRegistrationTests.cs',
+ templated: true
+ },
+ {
+ path: 'src/backend/tests/MyProject.Component.Tests/Services/BackgroundEmailServiceTests.cs',
+ templated: true
+ },
+ {
+ path: 'src/backend/tests/MyProject.Component.Tests/Services/EmailDeliveryJobTests.cs',
+ templated: true
+ },
{
path: 'src/backend/tests/MyProject.Component.Tests/Services/FluidEmailTemplateRendererTests.cs',
templated: false
diff --git a/packages/core/tests/integration/__snapshots__/snapshot.test.ts.snap b/packages/core/tests/integration/__snapshots__/snapshot.test.ts.snap
index 6fca5f8..d7c0dbb 100644
--- a/packages/core/tests/integration/__snapshots__/snapshot.test.ts.snap
+++ b/packages/core/tests/integration/__snapshots__/snapshot.test.ts.snap
@@ -145,13 +145,20 @@ exports[`convergence file snapshots > ErrorMessages.cs > core-only 1`] = `
"namespace SnapshotApp.Shared;
///
-/// User-facing error messages organized by domain area.
-/// Constants are used in Result.Failure() calls so that messages remain consistent,
-/// greppable, and easy to extract into translation keys later.
+/// User-facing errors organized by domain area. Each entry is an pairing a stable,
+/// machine-readable code with a human-readable message. Entries are used in Result.Failure() calls
+/// so that messages remain consistent, greppable, and translatable by clients via the code.
///
-/// All client-facing messages must be static constants - never interpolate runtime values
+/// Codes follow {nested_class}_{field_name} in snake_case and are verified by
+/// ErrorMessagesTests. Renaming a code is a breaking change for API consumers.
+///
+///
+/// All client-facing messages must be static - never interpolate runtime values
/// (role names, user IDs, framework error descriptions) into error responses.
-/// Log runtime details server-side via ILogger instead.
+/// Log runtime details server-side via ILogger instead. The only exceptions are
+/// entries explicitly documented as carrying a dynamic message (password policy feedback,
+/// rate-limit retry hints); those keep their stable code and override
+/// with a with expression.
///
///
public static class ErrorMessages
@@ -163,8 +170,8 @@ public static class ErrorMessages
///
public static class Pagination
{
- public const string InvalidPage = "Page number must be positive.";
- public const string InvalidPageSize = "Page size must be positive.";
+ public static readonly Error InvalidPage = new("pagination_invalid_page", "Page number must be positive.");
+ public static readonly Error InvalidPageSize = new("pagination_invalid_page_size", "Page size must be positive.");
}
///
@@ -172,7 +179,13 @@ public static class ErrorMessages
///
public static class Server
{
- public const string InternalError = "An internal error occurred.";
+ public static readonly Error InternalError = new("server_internal_error", "An internal error occurred.");
+
+ ///
+ /// Request rejected by the rate limiter. The message is overridden with the retry hint
+ /// (seconds until the window resets).
+ ///
+ public static readonly Error TooManyRequests = new("server_too_many_requests", "Too many requests. Please try again later.");
}
@@ -181,19 +194,20 @@ public static class ErrorMessages
///
public static class Security
{
- public const string CrossOriginRequestBlocked = "Cross-origin requests are not allowed.";
+ public static readonly Error CrossOriginRequestBlocked = new("security_cross_origin_request_blocked", "Cross-origin requests are not allowed.");
}
+
///
/// Generic entity operation error messages (repository layer).
///
public static class Entity
{
- public const string AddFailed = "Failed to add entity.";
- public const string NotFound = "Entity not found.";
- public const string NotDeleted = "Entity could not be deleted.";
+ public static readonly Error AddFailed = new("entity_add_failed", "Failed to add entity.");
+ public static readonly Error NotFound = new("entity_not_found", "Entity not found.");
+ public static readonly Error NotDeleted = new("entity_not_deleted", "Entity could not be deleted.");
}
}
"
@@ -203,13 +217,20 @@ exports[`convergence file snapshots > ErrorMessages.cs > full preset 1`] = `
"namespace SnapshotApp.Shared;
///
-/// User-facing error messages organized by domain area.
-/// Constants are used in Result.Failure() calls so that messages remain consistent,
-/// greppable, and easy to extract into translation keys later.
+/// User-facing errors organized by domain area. Each entry is an pairing a stable,
+/// machine-readable code with a human-readable message. Entries are used in Result.Failure() calls
+/// so that messages remain consistent, greppable, and translatable by clients via the code.
///
-/// All client-facing messages must be static constants - never interpolate runtime values
+/// Codes follow {nested_class}_{field_name} in snake_case and are verified by
+/// ErrorMessagesTests. Renaming a code is a breaking change for API consumers.
+///
+///
+/// All client-facing messages must be static - never interpolate runtime values
/// (role names, user IDs, framework error descriptions) into error responses.
-/// Log runtime details server-side via ILogger instead.
+/// Log runtime details server-side via ILogger instead. The only exceptions are
+/// entries explicitly documented as carrying a dynamic message (password policy feedback,
+/// rate-limit retry hints); those keep their stable code and override
+/// with a with expression.
///
///
public static class ErrorMessages
@@ -219,25 +240,37 @@ public static class ErrorMessages
///
public static class Auth
{
- public const string LoginInvalidCredentials = "Invalid username or password.";
- public const string LoginAccountLocked = "Account is temporarily locked. Please try again later or contact an administrator.";
- public const string RegisterRoleAssignFailed = "Account was created but role assignment failed. Please contact an administrator.";
- public const string TokenMissing = "Refresh token is missing.";
- public const string TokenNotFound = "Refresh token not found.";
- public const string TokenInvalidated = "Refresh token has been invalidated.";
- public const string TokenReused = "Invalid refresh token.";
- public const string TokenExpired = "Refresh token has expired.";
- public const string TokenUserNotFound = "Token owner not found.";
- public const string NotAuthenticated = "User is not authenticated.";
- public const string InsufficientPermissions = "You do not have the required permissions for this action.";
- public const string UserNotFound = "User not found.";
- public const string PasswordIncorrect = "Current password is incorrect.";
- public const string ResetPasswordFailed = "Password reset failed. The link may have expired or already been used.";
- public const string ResetPasswordTokenInvalid = "Invalid or expired password reset token.";
- public const string EmailVerificationFailed = "Email verification failed. The link may have expired or already been used.";
- public const string EmailAlreadyVerified = "Email address is already verified.";
- public const string PasswordSameAsCurrent = "New password must be different from your current password.";
- public const string CaptchaInvalid = "CAPTCHA verification failed. Please try again.";
+ public static readonly Error LoginInvalidCredentials = new("auth_login_invalid_credentials", "Invalid username or password.");
+ public static readonly Error LoginAccountLocked = new("auth_login_account_locked", "Account is temporarily locked. Please try again later or contact an administrator.");
+ public static readonly Error RegisterRoleAssignFailed = new("auth_register_role_assign_failed", "Account was created but role assignment failed. Please contact an administrator.");
+ public static readonly Error TokenMissing = new("auth_token_missing", "Refresh token is missing.");
+ public static readonly Error TokenNotFound = new("auth_token_not_found", "Refresh token not found.");
+ public static readonly Error TokenInvalidated = new("auth_token_invalidated", "Refresh token has been invalidated.");
+ public static readonly Error TokenReused = new("auth_token_reused", "Invalid refresh token.");
+ public static readonly Error TokenExpired = new("auth_token_expired", "Refresh token has expired.");
+ public static readonly Error TokenUserNotFound = new("auth_token_user_not_found", "Token owner not found.");
+ public static readonly Error NotAuthenticated = new("auth_not_authenticated", "User is not authenticated.");
+ public static readonly Error InsufficientPermissions = new("auth_insufficient_permissions", "You do not have the required permissions for this action.");
+ public static readonly Error UserNotFound = new("auth_user_not_found", "User not found.");
+ public static readonly Error PasswordIncorrect = new("auth_password_incorrect", "Current password is incorrect.");
+ public static readonly Error ResetPasswordFailed = new("auth_reset_password_failed", "Password reset failed. The link may have expired or already been used.");
+ public static readonly Error ResetPasswordTokenInvalid = new("auth_reset_password_token_invalid", "Invalid or expired password reset token.");
+ public static readonly Error EmailVerificationFailed = new("auth_email_verification_failed", "Email verification failed. The link may have expired or already been used.");
+ public static readonly Error EmailAlreadyVerified = new("auth_email_already_verified", "Email address is already verified.");
+ public static readonly Error PasswordSameAsCurrent = new("auth_password_same_as_current", "New password must be different from your current password.");
+ public static readonly Error CaptchaInvalid = new("auth_captcha_invalid", "CAPTCHA verification failed. Please try again.");
+
+ ///
+ /// Registration rejected by ASP.NET Identity (password policy, duplicate email). The message is
+ /// overridden with the Identity error descriptions so users get actionable feedback.
+ ///
+ public static readonly Error RegistrationInvalid = new("auth_registration_invalid", "Registration failed. Please check the provided details.");
+
+ ///
+ /// New password rejected by the password policy. The message is overridden with the
+ /// Identity error descriptions so users get actionable feedback.
+ ///
+ public static readonly Error PasswordPolicyViolation = new("auth_password_policy_violation", "The new password does not meet the password requirements.");
}
///
@@ -245,15 +278,15 @@ public static class ErrorMessages
///
public static class TwoFactor
{
- public const string SetupFailed = "Failed to set up two-factor authentication.";
- public const string VerificationFailed = "The verification code is invalid. Please try again.";
- public const string AlreadyEnabled = "Two-factor authentication is already enabled.";
- public const string NotEnabled = "Two-factor authentication is not enabled.";
- public const string DisableFailed = "Failed to disable two-factor authentication.";
- public const string ChallengeNotFound = "Two-factor challenge not found or expired.";
- public const string ChallengeLocked = "Too many failed attempts. Please log in again.";
- public const string RecoveryCodeInvalid = "The recovery code is invalid.";
- public const string InvalidCode = "The two-factor code is invalid.";
+ public static readonly Error SetupFailed = new("two_factor_setup_failed", "Failed to set up two-factor authentication.");
+ public static readonly Error VerificationFailed = new("two_factor_verification_failed", "The verification code is invalid. Please try again.");
+ public static readonly Error AlreadyEnabled = new("two_factor_already_enabled", "Two-factor authentication is already enabled.");
+ public static readonly Error NotEnabled = new("two_factor_not_enabled", "Two-factor authentication is not enabled.");
+ public static readonly Error DisableFailed = new("two_factor_disable_failed", "Failed to disable two-factor authentication.");
+ public static readonly Error ChallengeNotFound = new("two_factor_challenge_not_found", "Two-factor challenge not found or expired.");
+ public static readonly Error ChallengeLocked = new("two_factor_challenge_locked", "Too many failed attempts. Please log in again.");
+ public static readonly Error RecoveryCodeInvalid = new("two_factor_recovery_code_invalid", "The recovery code is invalid.");
+ public static readonly Error InvalidCode = new("two_factor_invalid_code", "The two-factor code is invalid.");
}
///
@@ -261,13 +294,13 @@ public static class ErrorMessages
///
public static class User
{
- public const string NotAuthenticated = "User is not authenticated.";
- public const string NotFound = "User not found.";
- public const string DeleteInvalidPassword = "Invalid password.";
- public const string PhoneNumberTaken = "This phone number is already in use.";
- public const string UpdateFailed = "Failed to update profile.";
- public const string DeleteFailed = "Failed to delete account.";
- public const string LastSuperuserCannotDelete = "Cannot delete your account while you are the last superuser.";
+ public static readonly Error NotAuthenticated = new("user_not_authenticated", "User is not authenticated.");
+ public static readonly Error NotFound = new("user_not_found", "User not found.");
+ public static readonly Error DeleteInvalidPassword = new("user_delete_invalid_password", "Invalid password.");
+ public static readonly Error PhoneNumberTaken = new("user_phone_number_taken", "This phone number is already in use.");
+ public static readonly Error UpdateFailed = new("user_update_failed", "Failed to update profile.");
+ public static readonly Error DeleteFailed = new("user_delete_failed", "Failed to delete account.");
+ public static readonly Error LastSuperuserCannotDelete = new("user_last_superuser_cannot_delete", "Cannot delete your account while you are the last superuser.");
}
///
@@ -275,31 +308,31 @@ public static class ErrorMessages
///
public static class Admin
{
- public const string UserNotFound = "User not found.";
- public const string HierarchyInsufficient = "You do not have sufficient privileges to manage this user.";
- public const string RoleAssignAboveRank = "Cannot assign a role at or above your own rank.";
- public const string RoleRemoveAboveRank = "Cannot remove a role at or above your own rank.";
- public const string RoleSelfRemove = "Cannot remove a role from your own account.";
- public const string LockSelfAction = "Cannot lock your own account.";
- public const string DeleteSelfAction = "Cannot delete your own account.";
- public const string EmailVerificationRequired = "User must have a verified email address before being assigned this role.";
- public const string EmailAlreadyRegistered = "A user with this email address already exists.";
- public const string RoleAssignEscalation = "Cannot assign a role that grants permissions you do not hold.";
- public const string RoleNotFound = "Role not found.";
- public const string RoleAlreadyAssigned = "User already has this role.";
- public const string RoleNotAssigned = "User does not have this role.";
- public const string LastRoleHolder = "Cannot remove this role - this is the last user holding it.";
- public const string RoleAssignFailed = "Failed to assign role.";
- public const string RoleRemoveFailed = "Failed to remove role.";
- public const string LockFailed = "Failed to lock user account.";
- public const string UnlockFailed = "Failed to unlock user account.";
- public const string DeleteFailed = "Failed to delete user account.";
- public const string EmailVerificationFailed = "Failed to verify email address.";
- public const string CreateUserFailed = "Failed to create user account.";
- public const string LastSuperuserCannotDelete = "Cannot delete this user - they are the last superuser.";
- public const string TwoFactorNotEnabled = "Two-factor authentication is not enabled for this user.";
- public const string DisableTwoFactorSelfAction = "You cannot disable your own two-factor authentication from the admin panel.";
- public const string DisableTwoFactorFailed = "Failed to disable two-factor authentication.";
+ public static readonly Error UserNotFound = new("admin_user_not_found", "User not found.");
+ public static readonly Error HierarchyInsufficient = new("admin_hierarchy_insufficient", "You do not have sufficient privileges to manage this user.");
+ public static readonly Error RoleAssignAboveRank = new("admin_role_assign_above_rank", "Cannot assign a role at or above your own rank.");
+ public static readonly Error RoleRemoveAboveRank = new("admin_role_remove_above_rank", "Cannot remove a role at or above your own rank.");
+ public static readonly Error RoleSelfRemove = new("admin_role_self_remove", "Cannot remove a role from your own account.");
+ public static readonly Error LockSelfAction = new("admin_lock_self_action", "Cannot lock your own account.");
+ public static readonly Error DeleteSelfAction = new("admin_delete_self_action", "Cannot delete your own account.");
+ public static readonly Error EmailVerificationRequired = new("admin_email_verification_required", "User must have a verified email address before being assigned this role.");
+ public static readonly Error EmailAlreadyRegistered = new("admin_email_already_registered", "A user with this email address already exists.");
+ public static readonly Error RoleAssignEscalation = new("admin_role_assign_escalation", "Cannot assign a role that grants permissions you do not hold.");
+ public static readonly Error RoleNotFound = new("admin_role_not_found", "Role not found.");
+ public static readonly Error RoleAlreadyAssigned = new("admin_role_already_assigned", "User already has this role.");
+ public static readonly Error RoleNotAssigned = new("admin_role_not_assigned", "User does not have this role.");
+ public static readonly Error LastRoleHolder = new("admin_last_role_holder", "Cannot remove this role - this is the last user holding it.");
+ public static readonly Error RoleAssignFailed = new("admin_role_assign_failed", "Failed to assign role.");
+ public static readonly Error RoleRemoveFailed = new("admin_role_remove_failed", "Failed to remove role.");
+ public static readonly Error LockFailed = new("admin_lock_failed", "Failed to lock user account.");
+ public static readonly Error UnlockFailed = new("admin_unlock_failed", "Failed to unlock user account.");
+ public static readonly Error DeleteFailed = new("admin_delete_failed", "Failed to delete user account.");
+ public static readonly Error EmailVerificationFailed = new("admin_email_verification_failed", "Failed to verify email address.");
+ public static readonly Error CreateUserFailed = new("admin_create_user_failed", "Failed to create user account.");
+ public static readonly Error LastSuperuserCannotDelete = new("admin_last_superuser_cannot_delete", "Cannot delete this user - they are the last superuser.");
+ public static readonly Error TwoFactorNotEnabled = new("admin_two_factor_not_enabled", "Two-factor authentication is not enabled for this user.");
+ public static readonly Error DisableTwoFactorSelfAction = new("admin_disable_two_factor_self_action", "You cannot disable your own two-factor authentication from the admin panel.");
+ public static readonly Error DisableTwoFactorFailed = new("admin_disable_two_factor_failed", "Failed to disable two-factor authentication.");
}
///
@@ -307,18 +340,18 @@ public static class ErrorMessages
///
public static class Roles
{
- public const string SystemRoleCannotBeDeleted = "System roles cannot be deleted.";
- public const string SystemRoleCannotBeRenamed = "System roles cannot be renamed.";
- public const string RoleNotFound = "Role not found.";
- public const string RoleNameTaken = "A role with this name already exists.";
- public const string RoleHasUsers = "Cannot delete a role that has users assigned to it.";
- public const string InvalidPermission = "One or more permission values are invalid.";
- public const string SystemRoleNameReserved = "This name is reserved for a system role.";
- public const string SuperuserPermissionsFixed = "Superuser permissions cannot be modified.";
- public const string CannotGrantUnheldPermission = "Cannot grant permissions that you do not hold.";
- public const string CreateFailed = "Failed to create role.";
- public const string UpdateFailed = "Failed to update role.";
- public const string DeleteFailed = "Failed to delete role.";
+ public static readonly Error SystemRoleCannotBeDeleted = new("roles_system_role_cannot_be_deleted", "System roles cannot be deleted.");
+ public static readonly Error SystemRoleCannotBeRenamed = new("roles_system_role_cannot_be_renamed", "System roles cannot be renamed.");
+ public static readonly Error RoleNotFound = new("roles_role_not_found", "Role not found.");
+ public static readonly Error RoleNameTaken = new("roles_role_name_taken", "A role with this name already exists.");
+ public static readonly Error RoleHasUsers = new("roles_role_has_users", "Cannot delete a role that has users assigned to it.");
+ public static readonly Error InvalidPermission = new("roles_invalid_permission", "One or more permission values are invalid.");
+ public static readonly Error SystemRoleNameReserved = new("roles_system_role_name_reserved", "This name is reserved for a system role.");
+ public static readonly Error SuperuserPermissionsFixed = new("roles_superuser_permissions_fixed", "Superuser permissions cannot be modified.");
+ public static readonly Error CannotGrantUnheldPermission = new("roles_cannot_grant_unheld_permission", "Cannot grant permissions that you do not hold.");
+ public static readonly Error CreateFailed = new("roles_create_failed", "Failed to create role.");
+ public static readonly Error UpdateFailed = new("roles_update_failed", "Failed to update role.");
+ public static readonly Error DeleteFailed = new("roles_delete_failed", "Failed to delete role.");
}
///
@@ -326,8 +359,8 @@ public static class ErrorMessages
///
public static class Pagination
{
- public const string InvalidPage = "Page number must be positive.";
- public const string InvalidPageSize = "Page size must be positive.";
+ public static readonly Error InvalidPage = new("pagination_invalid_page", "Page number must be positive.");
+ public static readonly Error InvalidPageSize = new("pagination_invalid_page_size", "Page size must be positive.");
}
///
@@ -335,7 +368,13 @@ public static class ErrorMessages
///
public static class Server
{
- public const string InternalError = "An internal error occurred.";
+ public static readonly Error InternalError = new("server_internal_error", "An internal error occurred.");
+
+ ///
+ /// Request rejected by the rate limiter. The message is overridden with the retry hint
+ /// (seconds until the window resets).
+ ///
+ public static readonly Error TooManyRequests = new("server_too_many_requests", "Too many requests. Please try again later.");
}
///
@@ -343,9 +382,9 @@ public static class ErrorMessages
///
public static class Jobs
{
- public const string NotFound = "Job not found.";
- public const string TriggerFailed = "Failed to trigger job.";
- public const string RestoreFailed = "Failed to restore jobs.";
+ public static readonly Error NotFound = new("jobs_not_found", "Job not found.");
+ public static readonly Error TriggerFailed = new("jobs_trigger_failed", "Failed to trigger job.");
+ public static readonly Error RestoreFailed = new("jobs_restore_failed", "Failed to restore jobs.");
}
///
@@ -353,7 +392,7 @@ public static class ErrorMessages
///
public static class Security
{
- public const string CrossOriginRequestBlocked = "Cross-origin requests are not allowed.";
+ public static readonly Error CrossOriginRequestBlocked = new("security_cross_origin_request_blocked", "Cross-origin requests are not allowed.");
}
///
@@ -361,10 +400,21 @@ public static class ErrorMessages
///
public static class Avatar
{
- public const string FileTooLarge = "The file exceeds the maximum allowed size of 5 MB.";
- public const string UnsupportedFormat = "Unsupported image format. Allowed formats: JPEG, PNG, WebP, GIF.";
- public const string ProcessingFailed = "Failed to process the avatar image.";
- public const string NotFound = "Avatar not found.";
+ public static readonly Error FileTooLarge = new("avatar_file_too_large", "The file exceeds the maximum allowed size of 5 MB.");
+ public static readonly Error UnsupportedFormat = new("avatar_unsupported_format", "Unsupported image format. Allowed formats: JPEG, PNG, WebP, GIF.");
+ public static readonly Error ProcessingFailed = new("avatar_processing_failed", "Failed to process the avatar image.");
+ public static readonly Error NotFound = new("avatar_not_found", "Avatar not found.");
+ }
+
+ ///
+ /// File storage (S3-compatible) error messages.
+ ///
+ public static class FileStorage
+ {
+ public static readonly Error UploadFailed = new("file_storage_upload_failed", "Failed to upload file to storage.");
+ public static readonly Error DownloadFailed = new("file_storage_download_failed", "Failed to retrieve file from storage.");
+ public static readonly Error DeleteFailed = new("file_storage_delete_failed", "Failed to delete file from storage.");
+ public static readonly Error NotFound = new("file_storage_not_found", "File not found.");
}
///
@@ -372,23 +422,23 @@ public static class ErrorMessages
///
public static class ExternalAuth
{
- public const string ProviderNotConfigured = "The requested authentication provider is not configured.";
- public const string InvalidState = "Invalid or missing OAuth state token.";
- public const string StateExpired = "OAuth state token has expired. Please try again.";
- public const string EmailNotVerified = "Your email address must be verified before linking an external account. Please verify your email first.";
- public const string AlreadyLinkedToOtherUser = "This external account is already linked to another user.";
- public const string ProviderNotLinked = "This provider is not linked to your account.";
- public const string CannotUnlinkLastMethod = "Cannot unlink this provider because it is your only sign-in method. Set a password first.";
- public const string CodeExchangeFailed = "Failed to exchange the authorization code with the provider.";
- public const string ProviderError = "The external authentication provider returned an error.";
- public const string InvalidRedirectUri = "The provided redirect URI is not allowed.";
- public const string PasswordAlreadySet = "A password is already set for this account.";
- public const string PasswordSetFailed = "Failed to set the password. Please try again.";
- public const string UnknownProvider = "The specified authentication provider is not recognized.";
- public const string ClientSecretRequired = "A client secret is required when enabling a provider that has no existing secret.";
- public const string TestConnectionInvalidCredentials = "The provider rejected the credentials. Verify the client ID and secret are correct.";
- public const string TestConnectionProviderUnreachable = "Could not reach the authentication provider. Please try again later.";
- public const string TestConnectionNotConfigured = "No credentials are configured for this provider.";
+ public static readonly Error ProviderNotConfigured = new("external_auth_provider_not_configured", "The requested authentication provider is not configured.");
+ public static readonly Error InvalidState = new("external_auth_invalid_state", "Invalid or missing OAuth state token.");
+ public static readonly Error StateExpired = new("external_auth_state_expired", "OAuth state token has expired. Please try again.");
+ public static readonly Error EmailNotVerified = new("external_auth_email_not_verified", "Your email address must be verified before linking an external account. Please verify your email first.");
+ public static readonly Error AlreadyLinkedToOtherUser = new("external_auth_already_linked_to_other_user", "This external account is already linked to another user.");
+ public static readonly Error ProviderNotLinked = new("external_auth_provider_not_linked", "This provider is not linked to your account.");
+ public static readonly Error CannotUnlinkLastMethod = new("external_auth_cannot_unlink_last_method", "Cannot unlink this provider because it is your only sign-in method. Set a password first.");
+ public static readonly Error CodeExchangeFailed = new("external_auth_code_exchange_failed", "Failed to exchange the authorization code with the provider.");
+ public static readonly Error ProviderError = new("external_auth_provider_error", "The external authentication provider returned an error.");
+ public static readonly Error InvalidRedirectUri = new("external_auth_invalid_redirect_uri", "The provided redirect URI is not allowed.");
+ public static readonly Error PasswordAlreadySet = new("external_auth_password_already_set", "A password is already set for this account.");
+ public static readonly Error PasswordSetFailed = new("external_auth_password_set_failed", "Failed to set the password. Please try again.");
+ public static readonly Error UnknownProvider = new("external_auth_unknown_provider", "The specified authentication provider is not recognized.");
+ public static readonly Error ClientSecretRequired = new("external_auth_client_secret_required", "A client secret is required when enabling a provider that has no existing secret.");
+ public static readonly Error TestConnectionInvalidCredentials = new("external_auth_test_connection_invalid_credentials", "The provider rejected the credentials. Verify the client ID and secret are correct.");
+ public static readonly Error TestConnectionProviderUnreachable = new("external_auth_test_connection_provider_unreachable", "Could not reach the authentication provider. Please try again later.");
+ public static readonly Error TestConnectionNotConfigured = new("external_auth_test_connection_not_configured", "No credentials are configured for this provider.");
}
///
@@ -396,9 +446,9 @@ public static class ErrorMessages
///
public static class Entity
{
- public const string AddFailed = "Failed to add entity.";
- public const string NotFound = "Entity not found.";
- public const string NotDeleted = "Entity could not be deleted.";
+ public static readonly Error AddFailed = new("entity_add_failed", "Failed to add entity.");
+ public static readonly Error NotFound = new("entity_not_found", "Entity not found.");
+ public static readonly Error NotDeleted = new("entity_not_deleted", "Entity could not be deleted.");
}
}
"
@@ -408,13 +458,20 @@ exports[`convergence file snapshots > ErrorMessages.cs > minimal (core + auth) 1
"namespace SnapshotApp.Shared;
///
-/// User-facing error messages organized by domain area.
-/// Constants are used in Result.Failure() calls so that messages remain consistent,
-/// greppable, and easy to extract into translation keys later.
+/// User-facing errors organized by domain area. Each entry is an pairing a stable,
+/// machine-readable code with a human-readable message. Entries are used in Result.Failure() calls
+/// so that messages remain consistent, greppable, and translatable by clients via the code.
///
-/// All client-facing messages must be static constants - never interpolate runtime values
+/// Codes follow {nested_class}_{field_name} in snake_case and are verified by
+/// ErrorMessagesTests. Renaming a code is a breaking change for API consumers.
+///
+///
+/// All client-facing messages must be static - never interpolate runtime values
/// (role names, user IDs, framework error descriptions) into error responses.
-/// Log runtime details server-side via ILogger instead.
+/// Log runtime details server-side via ILogger instead. The only exceptions are
+/// entries explicitly documented as carrying a dynamic message (password policy feedback,
+/// rate-limit retry hints); those keep their stable code and override
+/// with a with expression.
///
///
public static class ErrorMessages
@@ -424,25 +481,37 @@ public static class ErrorMessages
///
public static class Auth
{
- public const string LoginInvalidCredentials = "Invalid username or password.";
- public const string LoginAccountLocked = "Account is temporarily locked. Please try again later or contact an administrator.";
- public const string RegisterRoleAssignFailed = "Account was created but role assignment failed. Please contact an administrator.";
- public const string TokenMissing = "Refresh token is missing.";
- public const string TokenNotFound = "Refresh token not found.";
- public const string TokenInvalidated = "Refresh token has been invalidated.";
- public const string TokenReused = "Invalid refresh token.";
- public const string TokenExpired = "Refresh token has expired.";
- public const string TokenUserNotFound = "Token owner not found.";
- public const string NotAuthenticated = "User is not authenticated.";
- public const string InsufficientPermissions = "You do not have the required permissions for this action.";
- public const string UserNotFound = "User not found.";
- public const string PasswordIncorrect = "Current password is incorrect.";
- public const string ResetPasswordFailed = "Password reset failed. The link may have expired or already been used.";
- public const string ResetPasswordTokenInvalid = "Invalid or expired password reset token.";
- public const string EmailVerificationFailed = "Email verification failed. The link may have expired or already been used.";
- public const string EmailAlreadyVerified = "Email address is already verified.";
- public const string PasswordSameAsCurrent = "New password must be different from your current password.";
- public const string CaptchaInvalid = "CAPTCHA verification failed. Please try again.";
+ public static readonly Error LoginInvalidCredentials = new("auth_login_invalid_credentials", "Invalid username or password.");
+ public static readonly Error LoginAccountLocked = new("auth_login_account_locked", "Account is temporarily locked. Please try again later or contact an administrator.");
+ public static readonly Error RegisterRoleAssignFailed = new("auth_register_role_assign_failed", "Account was created but role assignment failed. Please contact an administrator.");
+ public static readonly Error TokenMissing = new("auth_token_missing", "Refresh token is missing.");
+ public static readonly Error TokenNotFound = new("auth_token_not_found", "Refresh token not found.");
+ public static readonly Error TokenInvalidated = new("auth_token_invalidated", "Refresh token has been invalidated.");
+ public static readonly Error TokenReused = new("auth_token_reused", "Invalid refresh token.");
+ public static readonly Error TokenExpired = new("auth_token_expired", "Refresh token has expired.");
+ public static readonly Error TokenUserNotFound = new("auth_token_user_not_found", "Token owner not found.");
+ public static readonly Error NotAuthenticated = new("auth_not_authenticated", "User is not authenticated.");
+ public static readonly Error InsufficientPermissions = new("auth_insufficient_permissions", "You do not have the required permissions for this action.");
+ public static readonly Error UserNotFound = new("auth_user_not_found", "User not found.");
+ public static readonly Error PasswordIncorrect = new("auth_password_incorrect", "Current password is incorrect.");
+ public static readonly Error ResetPasswordFailed = new("auth_reset_password_failed", "Password reset failed. The link may have expired or already been used.");
+ public static readonly Error ResetPasswordTokenInvalid = new("auth_reset_password_token_invalid", "Invalid or expired password reset token.");
+ public static readonly Error EmailVerificationFailed = new("auth_email_verification_failed", "Email verification failed. The link may have expired or already been used.");
+ public static readonly Error EmailAlreadyVerified = new("auth_email_already_verified", "Email address is already verified.");
+ public static readonly Error PasswordSameAsCurrent = new("auth_password_same_as_current", "New password must be different from your current password.");
+ public static readonly Error CaptchaInvalid = new("auth_captcha_invalid", "CAPTCHA verification failed. Please try again.");
+
+ ///
+ /// Registration rejected by ASP.NET Identity (password policy, duplicate email). The message is
+ /// overridden with the Identity error descriptions so users get actionable feedback.
+ ///
+ public static readonly Error RegistrationInvalid = new("auth_registration_invalid", "Registration failed. Please check the provided details.");
+
+ ///
+ /// New password rejected by the password policy. The message is overridden with the
+ /// Identity error descriptions so users get actionable feedback.
+ ///
+ public static readonly Error PasswordPolicyViolation = new("auth_password_policy_violation", "The new password does not meet the password requirements.");
}
///
@@ -450,15 +519,15 @@ public static class ErrorMessages
///
public static class TwoFactor
{
- public const string SetupFailed = "Failed to set up two-factor authentication.";
- public const string VerificationFailed = "The verification code is invalid. Please try again.";
- public const string AlreadyEnabled = "Two-factor authentication is already enabled.";
- public const string NotEnabled = "Two-factor authentication is not enabled.";
- public const string DisableFailed = "Failed to disable two-factor authentication.";
- public const string ChallengeNotFound = "Two-factor challenge not found or expired.";
- public const string ChallengeLocked = "Too many failed attempts. Please log in again.";
- public const string RecoveryCodeInvalid = "The recovery code is invalid.";
- public const string InvalidCode = "The two-factor code is invalid.";
+ public static readonly Error SetupFailed = new("two_factor_setup_failed", "Failed to set up two-factor authentication.");
+ public static readonly Error VerificationFailed = new("two_factor_verification_failed", "The verification code is invalid. Please try again.");
+ public static readonly Error AlreadyEnabled = new("two_factor_already_enabled", "Two-factor authentication is already enabled.");
+ public static readonly Error NotEnabled = new("two_factor_not_enabled", "Two-factor authentication is not enabled.");
+ public static readonly Error DisableFailed = new("two_factor_disable_failed", "Failed to disable two-factor authentication.");
+ public static readonly Error ChallengeNotFound = new("two_factor_challenge_not_found", "Two-factor challenge not found or expired.");
+ public static readonly Error ChallengeLocked = new("two_factor_challenge_locked", "Too many failed attempts. Please log in again.");
+ public static readonly Error RecoveryCodeInvalid = new("two_factor_recovery_code_invalid", "The recovery code is invalid.");
+ public static readonly Error InvalidCode = new("two_factor_invalid_code", "The two-factor code is invalid.");
}
///
@@ -466,13 +535,13 @@ public static class ErrorMessages
///
public static class User
{
- public const string NotAuthenticated = "User is not authenticated.";
- public const string NotFound = "User not found.";
- public const string DeleteInvalidPassword = "Invalid password.";
- public const string PhoneNumberTaken = "This phone number is already in use.";
- public const string UpdateFailed = "Failed to update profile.";
- public const string DeleteFailed = "Failed to delete account.";
- public const string LastSuperuserCannotDelete = "Cannot delete your account while you are the last superuser.";
+ public static readonly Error NotAuthenticated = new("user_not_authenticated", "User is not authenticated.");
+ public static readonly Error NotFound = new("user_not_found", "User not found.");
+ public static readonly Error DeleteInvalidPassword = new("user_delete_invalid_password", "Invalid password.");
+ public static readonly Error PhoneNumberTaken = new("user_phone_number_taken", "This phone number is already in use.");
+ public static readonly Error UpdateFailed = new("user_update_failed", "Failed to update profile.");
+ public static readonly Error DeleteFailed = new("user_delete_failed", "Failed to delete account.");
+ public static readonly Error LastSuperuserCannotDelete = new("user_last_superuser_cannot_delete", "Cannot delete your account while you are the last superuser.");
}
@@ -481,8 +550,8 @@ public static class ErrorMessages
///
public static class Pagination
{
- public const string InvalidPage = "Page number must be positive.";
- public const string InvalidPageSize = "Page size must be positive.";
+ public static readonly Error InvalidPage = new("pagination_invalid_page", "Page number must be positive.");
+ public static readonly Error InvalidPageSize = new("pagination_invalid_page_size", "Page size must be positive.");
}
///
@@ -490,7 +559,13 @@ public static class ErrorMessages
///
public static class Server
{
- public const string InternalError = "An internal error occurred.";
+ public static readonly Error InternalError = new("server_internal_error", "An internal error occurred.");
+
+ ///
+ /// Request rejected by the rate limiter. The message is overridden with the retry hint
+ /// (seconds until the window resets).
+ ///
+ public static readonly Error TooManyRequests = new("server_too_many_requests", "Too many requests. Please try again later.");
}
@@ -499,19 +574,20 @@ public static class ErrorMessages
///
public static class Security
{
- public const string CrossOriginRequestBlocked = "Cross-origin requests are not allowed.";
+ public static readonly Error CrossOriginRequestBlocked = new("security_cross_origin_request_blocked", "Cross-origin requests are not allowed.");
}
+
///
/// Generic entity operation error messages (repository layer).
///
public static class Entity
{
- public const string AddFailed = "Failed to add entity.";
- public const string NotFound = "Entity not found.";
- public const string NotDeleted = "Entity could not be deleted.";
+ public static readonly Error AddFailed = new("entity_add_failed", "Failed to add entity.");
+ public static readonly Error NotFound = new("entity_not_found", "Entity not found.");
+ public static readonly Error NotDeleted = new("entity_not_deleted", "Entity could not be deleted.");
}
}
"
@@ -593,6 +669,7 @@ using SnapshotApp.Infrastructure.Persistence.Extensions;
using SnapshotApp.WebApi.Extensions;
using SnapshotApp.WebApi.Features.OpenApi.Extensions;
using SnapshotApp.WebApi.Middlewares;
+using SnapshotApp.WebApi.Shared;
using Serilog;
using LoggerConfigurationExtensions = SnapshotApp.Infrastructure.Logging.Extensions.LoggerConfigurationExtensions;
@@ -652,6 +729,7 @@ try
options.CustomizeProblemDetails = context =>
{
context.ProblemDetails.Instance = context.HttpContext.Request.Path;
+ ProblemFactory.EnsureCode(context.ProblemDetails);
};
});
@@ -783,6 +861,7 @@ using SnapshotApp.WebApi.Extensions;
using SnapshotApp.WebApi.Features.OpenApi.Extensions;
using SnapshotApp.WebApi.Middlewares;
using SnapshotApp.WebApi.Routing;
+using SnapshotApp.WebApi.Shared;
using Serilog;
using LoggerConfigurationExtensions = SnapshotApp.Infrastructure.Logging.Extensions.LoggerConfigurationExtensions;
@@ -872,6 +951,7 @@ try
options.CustomizeProblemDetails = context =>
{
context.ProblemDetails.Instance = context.HttpContext.Request.Path;
+ ProblemFactory.EnsureCode(context.ProblemDetails);
};
});
@@ -1002,6 +1082,7 @@ using SnapshotApp.WebApi.Authorization;
using SnapshotApp.WebApi.Extensions;
using SnapshotApp.WebApi.Features.OpenApi.Extensions;
using SnapshotApp.WebApi.Middlewares;
+using SnapshotApp.WebApi.Shared;
using Serilog;
using LoggerConfigurationExtensions = SnapshotApp.Infrastructure.Logging.Extensions.LoggerConfigurationExtensions;
@@ -1076,6 +1157,7 @@ try
options.CustomizeProblemDetails = context =>
{
context.ProblemDetails.Instance = context.HttpContext.Request.Path;
+ ProblemFactory.EnsureCode(context.ProblemDetails);
};
});
@@ -1705,6 +1787,7 @@ exports[`file list snapshots > core + auth + 2fa + oauth file list 1`] = `
"src/backend/SnapshotApp.Infrastructure/SnapshotApp.Infrastructure.csproj",
"src/backend/SnapshotApp.ServiceDefaults/Extensions.cs",
"src/backend/SnapshotApp.ServiceDefaults/SnapshotApp.ServiceDefaults.csproj",
+ "src/backend/SnapshotApp.Shared/Error.cs",
"src/backend/SnapshotApp.Shared/ErrorMessages.cs",
"src/backend/SnapshotApp.Shared/ErrorType.cs",
"src/backend/SnapshotApp.Shared/PhoneNumberHelper.cs",
@@ -1778,6 +1861,7 @@ exports[`file list snapshots > core + auth + 2fa + oauth file list 1`] = `
"src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/CleanupDocumentTransformer.cs",
"src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/EnumSchemaTransformer.cs",
"src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/NumericSchemaTransformer.cs",
+ "src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/ProblemDetailsSchemaTransformer.cs",
"src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/ProjectDocumentTransformer.cs",
"src/backend/SnapshotApp.WebApi/Features/Users/Dtos/DeleteAccount/DeleteAccountRequest.cs",
"src/backend/SnapshotApp.WebApi/Features/Users/Dtos/DeleteAccount/DeleteAccountRequestValidator.cs",
@@ -1813,9 +1897,12 @@ exports[`file list snapshots > core + auth + 2fa + oauth file list 1`] = `
"src/backend/tests/SnapshotApp.Api.Tests/Controllers/OAuthProvidersControllerTests.cs",
"src/backend/tests/SnapshotApp.Api.Tests/Controllers/UsersControllerTests.cs",
"src/backend/tests/SnapshotApp.Api.Tests/Fixtures/CustomWebApplicationFactory.cs",
+ "src/backend/tests/SnapshotApp.Api.Tests/Fixtures/ProblemDetailsAssert.cs",
"src/backend/tests/SnapshotApp.Api.Tests/Fixtures/TestAuthHandler.cs",
"src/backend/tests/SnapshotApp.Api.Tests/GlobalUsings.cs",
"src/backend/tests/SnapshotApp.Api.Tests/Middlewares/OriginValidationMiddlewareTests.cs",
+ "src/backend/tests/SnapshotApp.Api.Tests/Middlewares/ProblemDetailsCodeTests.cs",
+ "src/backend/tests/SnapshotApp.Api.Tests/Shared/ProblemFactoryTests.cs",
"src/backend/tests/SnapshotApp.Api.Tests/SnapshotApp.Api.Tests.csproj",
"src/backend/tests/SnapshotApp.Api.Tests/Validation/CorsOptionsValidationTests.cs",
"src/backend/tests/SnapshotApp.Api.Tests/Validation/RateLimitingOptionsValidationTests.cs",
@@ -1908,6 +1995,7 @@ exports[`file list snapshots > core + jobs file list 1`] = `
"src/backend/SnapshotApp.Infrastructure/SnapshotApp.Infrastructure.csproj",
"src/backend/SnapshotApp.ServiceDefaults/Extensions.cs",
"src/backend/SnapshotApp.ServiceDefaults/SnapshotApp.ServiceDefaults.csproj",
+ "src/backend/SnapshotApp.Shared/Error.cs",
"src/backend/SnapshotApp.Shared/ErrorMessages.cs",
"src/backend/SnapshotApp.Shared/ErrorType.cs",
"src/backend/SnapshotApp.Shared/PhoneNumberHelper.cs",
@@ -1930,6 +2018,7 @@ exports[`file list snapshots > core + jobs file list 1`] = `
"src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/CleanupDocumentTransformer.cs",
"src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/EnumSchemaTransformer.cs",
"src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/NumericSchemaTransformer.cs",
+ "src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/ProblemDetailsSchemaTransformer.cs",
"src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/ProjectDocumentTransformer.cs",
"src/backend/SnapshotApp.WebApi/Middlewares/ExceptionHandlingMiddleware.cs",
"src/backend/SnapshotApp.WebApi/Middlewares/OriginValidationMiddleware.cs",
@@ -2001,6 +2090,7 @@ exports[`file list snapshots > core-only file list 1`] = `
"src/backend/SnapshotApp.Infrastructure/SnapshotApp.Infrastructure.csproj",
"src/backend/SnapshotApp.ServiceDefaults/Extensions.cs",
"src/backend/SnapshotApp.ServiceDefaults/SnapshotApp.ServiceDefaults.csproj",
+ "src/backend/SnapshotApp.Shared/Error.cs",
"src/backend/SnapshotApp.Shared/ErrorMessages.cs",
"src/backend/SnapshotApp.Shared/ErrorType.cs",
"src/backend/SnapshotApp.Shared/PhoneNumberHelper.cs",
@@ -2019,6 +2109,7 @@ exports[`file list snapshots > core-only file list 1`] = `
"src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/CleanupDocumentTransformer.cs",
"src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/EnumSchemaTransformer.cs",
"src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/NumericSchemaTransformer.cs",
+ "src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/ProblemDetailsSchemaTransformer.cs",
"src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/ProjectDocumentTransformer.cs",
"src/backend/SnapshotApp.WebApi/Middlewares/ExceptionHandlingMiddleware.cs",
"src/backend/SnapshotApp.WebApi/Middlewares/OriginValidationMiddleware.cs",
@@ -2165,6 +2256,7 @@ exports[`file list snapshots > frontend + admin (no jobs/oauth) file list 1`] =
"src/backend/SnapshotApp.Infrastructure/SnapshotApp.Infrastructure.csproj",
"src/backend/SnapshotApp.ServiceDefaults/Extensions.cs",
"src/backend/SnapshotApp.ServiceDefaults/SnapshotApp.ServiceDefaults.csproj",
+ "src/backend/SnapshotApp.Shared/Error.cs",
"src/backend/SnapshotApp.Shared/ErrorMessages.cs",
"src/backend/SnapshotApp.Shared/ErrorType.cs",
"src/backend/SnapshotApp.Shared/PhoneNumberHelper.cs",
@@ -2232,6 +2324,7 @@ exports[`file list snapshots > frontend + admin (no jobs/oauth) file list 1`] =
"src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/CleanupDocumentTransformer.cs",
"src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/EnumSchemaTransformer.cs",
"src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/NumericSchemaTransformer.cs",
+ "src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/ProblemDetailsSchemaTransformer.cs",
"src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/ProjectDocumentTransformer.cs",
"src/backend/SnapshotApp.WebApi/Features/Users/Dtos/DeleteAccount/DeleteAccountRequest.cs",
"src/backend/SnapshotApp.WebApi/Features/Users/Dtos/DeleteAccount/DeleteAccountRequestValidator.cs",
@@ -2267,9 +2360,12 @@ exports[`file list snapshots > frontend + admin (no jobs/oauth) file list 1`] =
"src/backend/tests/SnapshotApp.Api.Tests/Features/Admin/AdminMapperPiiTests.cs",
"src/backend/tests/SnapshotApp.Api.Tests/Features/Admin/PiiMaskerTests.cs",
"src/backend/tests/SnapshotApp.Api.Tests/Fixtures/CustomWebApplicationFactory.cs",
+ "src/backend/tests/SnapshotApp.Api.Tests/Fixtures/ProblemDetailsAssert.cs",
"src/backend/tests/SnapshotApp.Api.Tests/Fixtures/TestAuthHandler.cs",
"src/backend/tests/SnapshotApp.Api.Tests/GlobalUsings.cs",
"src/backend/tests/SnapshotApp.Api.Tests/Middlewares/OriginValidationMiddlewareTests.cs",
+ "src/backend/tests/SnapshotApp.Api.Tests/Middlewares/ProblemDetailsCodeTests.cs",
+ "src/backend/tests/SnapshotApp.Api.Tests/Shared/ProblemFactoryTests.cs",
"src/backend/tests/SnapshotApp.Api.Tests/SnapshotApp.Api.Tests.csproj",
"src/backend/tests/SnapshotApp.Api.Tests/Validation/CorsOptionsValidationTests.cs",
"src/backend/tests/SnapshotApp.Api.Tests/Validation/RateLimitingOptionsValidationTests.cs",
@@ -2849,6 +2945,7 @@ exports[`file list snapshots > frontend full file list 1`] = `
"src/backend/SnapshotApp.Infrastructure/SnapshotApp.Infrastructure.csproj",
"src/backend/SnapshotApp.ServiceDefaults/Extensions.cs",
"src/backend/SnapshotApp.ServiceDefaults/SnapshotApp.ServiceDefaults.csproj",
+ "src/backend/SnapshotApp.Shared/Error.cs",
"src/backend/SnapshotApp.Shared/ErrorMessages.cs",
"src/backend/SnapshotApp.Shared/ErrorType.cs",
"src/backend/SnapshotApp.Shared/PhoneNumberHelper.cs",
@@ -2953,6 +3050,7 @@ exports[`file list snapshots > frontend full file list 1`] = `
"src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/CleanupDocumentTransformer.cs",
"src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/EnumSchemaTransformer.cs",
"src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/NumericSchemaTransformer.cs",
+ "src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/ProblemDetailsSchemaTransformer.cs",
"src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/ProjectDocumentTransformer.cs",
"src/backend/SnapshotApp.WebApi/Features/Users/Dtos/DeleteAccount/DeleteAccountRequest.cs",
"src/backend/SnapshotApp.WebApi/Features/Users/Dtos/DeleteAccount/DeleteAccountRequestValidator.cs",
@@ -2996,9 +3094,12 @@ exports[`file list snapshots > frontend full file list 1`] = `
"src/backend/tests/SnapshotApp.Api.Tests/Features/Admin/AdminMapperPiiTests.cs",
"src/backend/tests/SnapshotApp.Api.Tests/Features/Admin/PiiMaskerTests.cs",
"src/backend/tests/SnapshotApp.Api.Tests/Fixtures/CustomWebApplicationFactory.cs",
+ "src/backend/tests/SnapshotApp.Api.Tests/Fixtures/ProblemDetailsAssert.cs",
"src/backend/tests/SnapshotApp.Api.Tests/Fixtures/TestAuthHandler.cs",
"src/backend/tests/SnapshotApp.Api.Tests/GlobalUsings.cs",
"src/backend/tests/SnapshotApp.Api.Tests/Middlewares/OriginValidationMiddlewareTests.cs",
+ "src/backend/tests/SnapshotApp.Api.Tests/Middlewares/ProblemDetailsCodeTests.cs",
+ "src/backend/tests/SnapshotApp.Api.Tests/Shared/ProblemFactoryTests.cs",
"src/backend/tests/SnapshotApp.Api.Tests/SnapshotApp.Api.Tests.csproj",
"src/backend/tests/SnapshotApp.Api.Tests/Validation/CorsOptionsValidationTests.cs",
"src/backend/tests/SnapshotApp.Api.Tests/Validation/RateLimitingOptionsValidationTests.cs",
@@ -3556,6 +3657,7 @@ exports[`file list snapshots > frontend minimal (core + auth + frontend) file li
"src/backend/SnapshotApp.Infrastructure/SnapshotApp.Infrastructure.csproj",
"src/backend/SnapshotApp.ServiceDefaults/Extensions.cs",
"src/backend/SnapshotApp.ServiceDefaults/SnapshotApp.ServiceDefaults.csproj",
+ "src/backend/SnapshotApp.Shared/Error.cs",
"src/backend/SnapshotApp.Shared/ErrorMessages.cs",
"src/backend/SnapshotApp.Shared/ErrorType.cs",
"src/backend/SnapshotApp.Shared/PhoneNumberHelper.cs",
@@ -3601,6 +3703,7 @@ exports[`file list snapshots > frontend minimal (core + auth + frontend) file li
"src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/CleanupDocumentTransformer.cs",
"src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/EnumSchemaTransformer.cs",
"src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/NumericSchemaTransformer.cs",
+ "src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/ProblemDetailsSchemaTransformer.cs",
"src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/ProjectDocumentTransformer.cs",
"src/backend/SnapshotApp.WebApi/Features/Users/Dtos/DeleteAccount/DeleteAccountRequest.cs",
"src/backend/SnapshotApp.WebApi/Features/Users/Dtos/DeleteAccount/DeleteAccountRequestValidator.cs",
@@ -3633,9 +3736,12 @@ exports[`file list snapshots > frontend minimal (core + auth + frontend) file li
"src/backend/tests/SnapshotApp.Api.Tests/Controllers/AuthControllerTests.cs",
"src/backend/tests/SnapshotApp.Api.Tests/Controllers/UsersControllerTests.cs",
"src/backend/tests/SnapshotApp.Api.Tests/Fixtures/CustomWebApplicationFactory.cs",
+ "src/backend/tests/SnapshotApp.Api.Tests/Fixtures/ProblemDetailsAssert.cs",
"src/backend/tests/SnapshotApp.Api.Tests/Fixtures/TestAuthHandler.cs",
"src/backend/tests/SnapshotApp.Api.Tests/GlobalUsings.cs",
"src/backend/tests/SnapshotApp.Api.Tests/Middlewares/OriginValidationMiddlewareTests.cs",
+ "src/backend/tests/SnapshotApp.Api.Tests/Middlewares/ProblemDetailsCodeTests.cs",
+ "src/backend/tests/SnapshotApp.Api.Tests/Shared/ProblemFactoryTests.cs",
"src/backend/tests/SnapshotApp.Api.Tests/SnapshotApp.Api.Tests.csproj",
"src/backend/tests/SnapshotApp.Api.Tests/Validation/CorsOptionsValidationTests.cs",
"src/backend/tests/SnapshotApp.Api.Tests/Validation/RateLimitingOptionsValidationTests.cs",
@@ -4245,6 +4351,7 @@ exports[`file list snapshots > full preset file list 1`] = `
"src/backend/SnapshotApp.Infrastructure/SnapshotApp.Infrastructure.csproj",
"src/backend/SnapshotApp.ServiceDefaults/Extensions.cs",
"src/backend/SnapshotApp.ServiceDefaults/SnapshotApp.ServiceDefaults.csproj",
+ "src/backend/SnapshotApp.Shared/Error.cs",
"src/backend/SnapshotApp.Shared/ErrorMessages.cs",
"src/backend/SnapshotApp.Shared/ErrorType.cs",
"src/backend/SnapshotApp.Shared/PhoneNumberHelper.cs",
@@ -4349,6 +4456,7 @@ exports[`file list snapshots > full preset file list 1`] = `
"src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/CleanupDocumentTransformer.cs",
"src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/EnumSchemaTransformer.cs",
"src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/NumericSchemaTransformer.cs",
+ "src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/ProblemDetailsSchemaTransformer.cs",
"src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/ProjectDocumentTransformer.cs",
"src/backend/SnapshotApp.WebApi/Features/Users/Dtos/DeleteAccount/DeleteAccountRequest.cs",
"src/backend/SnapshotApp.WebApi/Features/Users/Dtos/DeleteAccount/DeleteAccountRequestValidator.cs",
@@ -4392,9 +4500,12 @@ exports[`file list snapshots > full preset file list 1`] = `
"src/backend/tests/SnapshotApp.Api.Tests/Features/Admin/AdminMapperPiiTests.cs",
"src/backend/tests/SnapshotApp.Api.Tests/Features/Admin/PiiMaskerTests.cs",
"src/backend/tests/SnapshotApp.Api.Tests/Fixtures/CustomWebApplicationFactory.cs",
+ "src/backend/tests/SnapshotApp.Api.Tests/Fixtures/ProblemDetailsAssert.cs",
"src/backend/tests/SnapshotApp.Api.Tests/Fixtures/TestAuthHandler.cs",
"src/backend/tests/SnapshotApp.Api.Tests/GlobalUsings.cs",
"src/backend/tests/SnapshotApp.Api.Tests/Middlewares/OriginValidationMiddlewareTests.cs",
+ "src/backend/tests/SnapshotApp.Api.Tests/Middlewares/ProblemDetailsCodeTests.cs",
+ "src/backend/tests/SnapshotApp.Api.Tests/Shared/ProblemFactoryTests.cs",
"src/backend/tests/SnapshotApp.Api.Tests/SnapshotApp.Api.Tests.csproj",
"src/backend/tests/SnapshotApp.Api.Tests/Validation/CorsOptionsValidationTests.cs",
"src/backend/tests/SnapshotApp.Api.Tests/Validation/RateLimitingOptionsValidationTests.cs",
@@ -4490,6 +4601,7 @@ exports[`file list snapshots > minimal preset (core + auth) file list 1`] = `
"src/backend/SnapshotApp.Infrastructure/SnapshotApp.Infrastructure.csproj",
"src/backend/SnapshotApp.ServiceDefaults/Extensions.cs",
"src/backend/SnapshotApp.ServiceDefaults/SnapshotApp.ServiceDefaults.csproj",
+ "src/backend/SnapshotApp.Shared/Error.cs",
"src/backend/SnapshotApp.Shared/ErrorMessages.cs",
"src/backend/SnapshotApp.Shared/ErrorType.cs",
"src/backend/SnapshotApp.Shared/PhoneNumberHelper.cs",
@@ -4508,6 +4620,7 @@ exports[`file list snapshots > minimal preset (core + auth) file list 1`] = `
"src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/CleanupDocumentTransformer.cs",
"src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/EnumSchemaTransformer.cs",
"src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/NumericSchemaTransformer.cs",
+ "src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/ProblemDetailsSchemaTransformer.cs",
"src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/ProjectDocumentTransformer.cs",
"src/backend/SnapshotApp.WebApi/Middlewares/ExceptionHandlingMiddleware.cs",
"src/backend/SnapshotApp.WebApi/Middlewares/OriginValidationMiddleware.cs",
diff --git a/packages/web/package.json b/packages/web/package.json
index a9521a4..828fc4e 100644
--- a/packages/web/package.json
+++ b/packages/web/package.json
@@ -1,7 +1,7 @@
{
"name": "@netrock/web",
"private": true,
- "version": "0.9.5",
+ "version": "0.10.0",
"type": "module",
"scripts": {
"dev": "vite dev",
diff --git a/templates/.claude/agents/backend-reviewer.md b/templates/.claude/agents/backend-reviewer.md
index 08649ee..80d9058 100644
--- a/templates/.claude/agents/backend-reviewer.md
+++ b/templates/.claude/agents/backend-reviewer.md
@@ -18,7 +18,7 @@ WebApi -> Application <- Infrastructure
All layers reference Shared (Result, ErrorType, ErrorMessages)
```
-- **Shared**: `Result`/`Result`, `ErrorType`, `ErrorMessages`. Zero deps.
+- **Shared**: `Result`/`Result`, `Error` (code + message), `ErrorType`, `ErrorMessages`. Zero deps.
- **Domain**: Entities (`BaseEntity`). Zero deps.
- **Application**: Interfaces, DTOs (Input/Output), service contracts.
- **Infrastructure**: EF Core, Identity, services. All implementations `internal`.
@@ -28,7 +28,7 @@ All layers reference Shared (Result, ErrorType, ErrorMessages)
### Result Pattern
- All fallible operations return `Result`/`Result` - never throw for business logic
-- `Result.Failure(ErrorMessages.*, ErrorType.*)` with centralized constants
+- `Result.Failure(ErrorMessages.*, ErrorType.*)` with centralized `Error` entries (stable snake_case `code` + message; never rename a code)
- Runtime values in logs, never in Result messages
- Controllers use `ProblemFactory.Create(result.Error, result.ErrorType)` for failures
diff --git a/templates/.claude/rules/backend-api.md b/templates/.claude/rules/backend-api.md
index 9dcc06f..5f898aa 100644
--- a/templates/.claude/rules/backend-api.md
+++ b/templates/.claude/rules/backend-api.md
@@ -5,7 +5,7 @@ Extends CLAUDE.md Hard Rules with implementation patterns.
## Error Handling
- `ProblemFactory.Create(result.Error, result.ErrorType)` for error responses - never `NotFound()`, `BadRequest()`, or anonymous objects
-- Client-facing error messages: `ErrorMessages.*` constants only - runtime values go in `ILogger`, never in `Result.Failure()`
+- Client-facing errors: `ErrorMessages.*` entries only (`Error` = stable snake_case `code` + message) - runtime values go in `ILogger`, never in `Result.Failure()`. Every ProblemDetails carries `code`; renaming a code is a breaking change
## Controllers
diff --git a/templates/.claude/rules/frontend-svelte.md b/templates/.claude/rules/frontend-svelte.md
index 2327ed1..0018a32 100644
--- a/templates/.claude/rules/frontend-svelte.md
+++ b/templates/.claude/rules/frontend-svelte.md
@@ -8,7 +8,7 @@ Extends CLAUDE.md Hard Rules with implementation patterns.
## API Client
- File uploads: native `fetch()` with `FormData` - not `browserClient` (openapi-fetch breaks with multipart)
-- Error handling: `getErrorMessage(error, fallback)` for simple errors, `handleMutationError()` for forms with validation
+- Error handling: `getErrorMessage(error, fallback)` for simple errors, `getErrorMessage(error, fallback, messagesByCode)` / `getErrorCode()` to translate by backend `code` (never match `detail` text), `handleMutationError()` for forms with validation
## Styling
- `h-dvh` not `h-screen` for full-height layouts
diff --git a/templates/.claude/skills/add-background-job/SKILL.md b/templates/.claude/skills/add-background-job/SKILL.md
index 925e622..cbf83e7 100644
--- a/templates/.claude/skills/add-background-job/SKILL.md
+++ b/templates/.claude/skills/add-background-job/SKILL.md
@@ -60,35 +60,42 @@ services.AddScoped(sp => sp.GetRequiredService
+Note: transactional emails are already queued automatically - `ITemplatedEmailSender.SendSafeAsync()` routes through `BackgroundEmailService` -> `EmailDeliveryJob` (`Features/Email/Jobs/`) whenever email and job scheduling are both enabled. Do not wrap email sends in another job.
+
+
+**1. Create the job class** in `src/backend/MyProject.Infrastructure/Features/{Feature}/Jobs/` (or `Features/Jobs/` for cross-cutting jobs):
```csharp
-internal sealed class WelcomeEmailJob(
- ITemplatedEmailSender templatedEmailSender,
- ILogger logger)
+internal sealed class ReportGenerationJob(
+ IReportService reportService,
+ ILogger logger)
{
- public async Task ExecuteAsync(string userId, string email)
+ [AutomaticRetry(Attempts = 3, DelaysInSeconds = [30, 120, 600])]
+ public async Task ExecuteAsync(Guid reportId, CancellationToken cancellationToken)
{
- await templatedEmailSender.SendSafeAsync("welcome", new WelcomeModel(email), email, default);
- logger.LogInformation("Sent welcome email to user '{UserId}'", userId);
+ await reportService.GenerateAsync(reportId, cancellationToken);
+ logger.LogInformation("Generated report '{ReportId}'", reportId);
}
}
```
-All parameters must be **JSON-serializable** (Hangfire persists them). Never pass `IServiceProvider`, `HttpContext`, or `DbContext` as arguments.
+All parameters must be **JSON-serializable** (Hangfire persists them). Never pass `IServiceProvider`, `HttpContext`, or `DbContext` as arguments. A trailing `CancellationToken` parameter is injected by Hangfire (server shutdown) - pass `CancellationToken.None` in the enqueue expression.
+
+Let exceptions propagate - `[AutomaticRetry]` only retries when the method throws. Retries re-run the whole method, so keep it idempotent.
-**2. Register:** `services.AddScoped();`
+**2. Register:** `services.AddScoped();`
**3. Enqueue:**
```csharp
// Fire-and-forget
-backgroundJobClient.Enqueue(job => job.ExecuteAsync(user.Id, user.Email));
+backgroundJobClient.Enqueue(job => job.ExecuteAsync(reportId, CancellationToken.None));
// Delayed
-backgroundJobClient.Schedule(job => job.ExecuteAsync(user.Id, user.Email), TimeSpan.FromMinutes(30));
+backgroundJobClient.Schedule(job => job.ExecuteAsync(reportId, CancellationToken.None), TimeSpan.FromMinutes(30));
```
diff --git a/templates/.claude/skills/backend-conventions/SKILL.md b/templates/.claude/skills/backend-conventions/SKILL.md
index 7c059f9..44ea2b7 100644
--- a/templates/.claude/skills/backend-conventions/SKILL.md
+++ b/templates/.claude/skills/backend-conventions/SKILL.md
@@ -9,7 +9,7 @@ user-invocable: false
```
src/backend/
-├── MyProject.Shared/ # Result, ErrorType, ErrorMessages (zero deps)
+├── MyProject.Shared/ # Result, Error, ErrorType, ErrorMessages (zero deps)
├── MyProject.Domain/Entities/ # Business entities (BaseEntity)
├── MyProject.Application/ # Interfaces, DTOs, service contracts
│ ├── Features/{Feature}/I{Feature}Service.cs
@@ -98,7 +98,7 @@ dotnet ef migrations add {Name} \
// Success
return Result.Success(entity.Id);
-// Static message - prefer ErrorMessages constants
+// Static error (code + message) - always an ErrorMessages entry
return Result.Failure(ErrorMessages.Admin.UserNotFound, ErrorType.NotFound);
// Runtime values go in server-side logs, never in client responses
@@ -113,7 +113,7 @@ return Result.Failure(ErrorMessages.Admin.DeleteFailed);
| `ErrorType.Forbidden` | 403 | Authenticated but insufficient privileges |
| `ErrorType.NotFound` | 404 | Entity not found |
-Controller: `ProblemFactory.Create(result.Error, result.ErrorType)` for failures.
+Controller: `ProblemFactory.Create(result.Error, result.ErrorType)` for failures. `result.Error` is an `Error` record (`Code`, `Message`); the factory writes `Message` to `detail` and `Code` to the `code` extension.
## Service Pattern
@@ -157,13 +157,18 @@ FluentValidation auto-discovered from WebApi assembly. Co-locate validators with
| URL fields | `Uri.TryCreate` + restrict to `http`/`https` schemes |
| Shared patterns | Extract to `ValidationConstants.cs` |
-## Error Messages
+## Error Messages and Codes
-- Client-facing messages are centralized as `const string` in `ErrorMessages.cs` nested classes
+- Client-facing errors are centralized as `static readonly Error` entries in `ErrorMessages.cs` nested classes. `Error(Code, Message)` pairs a stable, machine-readable snake_case code with the human-readable message.
+- Codes are derived from the declaring location: `{nested_class}_{field_name}` in snake_case (`ErrorMessages.ExternalAuth.StateExpired` -> `external_auth_state_expired`). `ErrorMessagesTests` enforces the pattern and global uniqueness.
+- Every `ProblemDetails` response carries the code in the `code` extension (`ProblemFactory` for controllers/middleware, `ProblemFactory.EnsureCode` in `AddProblemDetails` for framework-generated bodies: `validation_failed` for model validation, snake_case reason phrase such as `not_found` otherwise). `ProblemDetailsSchemaTransformer` documents it in OpenAPI.
+- Codes are a public contract: adding one is additive, renaming or removing one is a breaking change for API consumers (frontend maps on codes, not on `detail` text).
- Runtime values (role names, user IDs, framework errors): log server-side via `ILogger`, never in `Result.Failure()`
-- Identity errors: log `.Description` server-side, return a static `ErrorMessages` constant to the client
-- Exception: password validation errors (registration, change, reset) are forwarded as-is
-- To add: create `const string` in `ErrorMessages.cs` nested class. Dynamic values go in logs, not in Result.
+# @feature auth
+- Identity errors: log `.Description` server-side, return a static `ErrorMessages` entry to the client
+- Exception: password policy / registration feedback keeps its stable code and overrides only the message: `ErrorMessages.Auth.PasswordPolicyViolation with { Message = errors }`
+# @end
+- To add: create `public static readonly Error X = new("{class}_{x}", "...")` in the matching `ErrorMessages.cs` nested class. Dynamic values go in logs, not in Result.
# @feature auth
## Authorization
diff --git a/templates/.claude/skills/new-entity/SKILL.md b/templates/.claude/skills/new-entity/SKILL.md
index e7c3184..2a5b223 100644
--- a/templates/.claude/skills/new-entity/SKILL.md
+++ b/templates/.claude/skills/new-entity/SKILL.md
@@ -24,7 +24,7 @@ Use these as starting points - fill in the specifics from context:
1. Create `src/backend/MyProject.Domain/Entities/{Entity}.cs`:
- Extend `BaseEntity`, private setters, protected parameterless ctor, public ctor with `Id = Guid.NewGuid()`
2. If enums: create alongside entity with explicit integer values
-3. Add error messages to `src/backend/MyProject.Shared/ErrorMessages.cs`
+3. Add `Error` entries (snake_case code + message) to `src/backend/MyProject.Shared/ErrorMessages.cs`
**Infrastructure:**
diff --git a/templates/.claude/skills/review-pr/references/conventions-summary.md b/templates/.claude/skills/review-pr/references/conventions-summary.md
index 9737788..c0c6334 100644
--- a/templates/.claude/skills/review-pr/references/conventions-summary.md
+++ b/templates/.claude/skills/review-pr/references/conventions-summary.md
@@ -34,7 +34,7 @@
## Error Flow
-Backend `ErrorMessages.*` -> `Result.Failure()` -> `ProblemFactory.Create()` -> `ProblemDetails.detail`
+Backend `ErrorMessages.*` (`Error`: code + message) -> `Result.Failure()` -> `ProblemFactory.Create()` -> `ProblemDetails.detail` + `code`
## Dockerfile
diff --git a/templates/FILEMAP.md b/templates/FILEMAP.md
index 0d29ff5..82366c2 100644
--- a/templates/FILEMAP.md
+++ b/templates/FILEMAP.md
@@ -28,8 +28,14 @@ Quick-reference for "when you change X, also update Y" and "where does X live?"
|---|---|
| **Domain entity** (add/rename property) | EF configuration, migration, Application DTOs, WebApi DTOs, mapper |
| **Domain entity** (add enum property) | EF config (`.HasComment()`), `EnumSchemaTransformer` handles the rest automatically |
-| **`ErrorMessages.cs`** (Shared - add/rename constant) | Service that uses it |
-| **`Result.cs`** (Shared - change pattern) | Every service + every controller that matches on `Result` |
+| **`ErrorMessages.cs`** (Shared - add/rename `Error` entry) | Service that uses it; `ErrorMessagesTests` (code must be `{nested_class}_{field_name}` snake_case); renaming a code is a breaking API change |
+| **`Result.cs`** / **`Error.cs`** (Shared - change pattern) | Every service + every controller that matches on `Result`; `ProblemFactory` |
+# @feature auth
+| **`ProblemFactory`** (WebApi - ProblemDetails shape, `code` extension) | `ProblemDetailsSchemaTransformer` (OpenAPI), `ExceptionHandlingMiddleware`, `OriginValidationMiddleware`, `RateLimiterExtensions`, `ProblemDetailsAuthorizationHandler`, `Program.cs` (`EnsureCode`), `ProblemDetailsAssert` + `ProblemDetailsCodeTests` (Api.Tests) |
+# @end
+# @feature !auth
+| **`ProblemFactory`** (WebApi - ProblemDetails shape, `code` extension) | `ProblemDetailsSchemaTransformer` (OpenAPI), `ExceptionHandlingMiddleware`, `OriginValidationMiddleware`, `RateLimiterExtensions`, `Program.cs` (`EnsureCode`) |
+# @end
| **Application interface** (change signature) | Infrastructure service implementation, controller calling the service |
| **Application DTO** (add/rename/remove field) | Infrastructure service, WebApi mapper, WebApi request/response DTO |
| **Infrastructure EF config** (change mapping) | Run new migration |
@@ -52,8 +58,17 @@ Quick-reference for "when you change X, also update Y" and "where does X live?"
| **`FileStorageOptions`** (change S3/MinIO config) | `appsettings.json`, `MyProject.AppHost/Program.cs` (`.WithEnvironment()`), `appsettings.Testing.json` |
# @end
# @feature auth
+# @feature jobs
+| **`EmailOptions`** (change config shape) | `appsettings.json`, `appsettings.Development.json`, `appsettings.Testing.json`, `ServiceCollectionExtensions` (email DI), `EmailOptionsValidationTests`, `EmailServiceRegistrationTests` |
+| **`IEmailService`** (change sending contract) | `NoOpEmailService`, `SmtpEmailService`, `BackgroundEmailService`, `EmailDeliveryJob`, `CustomWebApplicationFactory` |
+| **`SmtpEmailService`** (change SMTP delivery) | `EmailDeliveryJob` (job executor), `SmtpEmailServiceTests`, `EmailDeliveryJobTests` |
+| **`EmailDeliveryJob`** (change job signature/retry policy) | `BackgroundEmailService` (enqueue expression), `EmailDeliveryJobTests`, `BackgroundEmailServiceTests` |
+| **Email DI routing** (`AddEmailServices` - background vs direct vs no-op) | `EmailServiceRegistrationTests` |
+# @end
+# @feature !jobs
| **`EmailOptions`** (change config shape) | `appsettings.json`, `appsettings.Development.json`, `appsettings.Testing.json`, `ServiceCollectionExtensions` (email DI), `EmailOptionsValidationTests` |
| **`IEmailService`** (change sending contract) | `NoOpEmailService`, `SmtpEmailService`, `CustomWebApplicationFactory` |
+# @end
| **`IEmailTemplateRenderer`** (change rendering contract) | `FluidEmailTemplateRenderer`, `TemplatedEmailSender`, `FluidEmailTemplateRendererTests` |
| **`ITemplatedEmailSender`** (change send-safe contract) | `TemplatedEmailSender`, all services calling `SendSafeAsync()`, `TemplatedEmailSenderTests` |
| **`EmailTemplateModels.cs`** (add/rename model record) | Matching `.liquid` templates, `FluidEmailTemplateRenderer.CreateOptions()`, services that construct the model, `FluidEmailTemplateRendererTests` |
@@ -95,6 +110,9 @@ Quick-reference for "when you change X, also update Y" and "where does X live?"
# @feature jobs
| **`IRecurringJobDefinition`** (add new job) | Register in `ServiceCollectionExtensions.AddJobScheduling()`, job auto-discovered at startup |
| **Job scheduling config** (`ServiceCollectionExtensions.AddJobScheduling`) | `Program.cs` must call `AddJobScheduling()` and `UseJobScheduling()` |
+# @feature auth
+| **`JobSchedulingOptions`** (change `Enabled` semantics) | Email DI (`AddEmailServices` reads `JobScheduling:Enabled` to pick `BackgroundEmailService`), `EmailServiceRegistrationTests` |
+# @end
# @end
| **`RateLimitPolicies.cs`** (add/rename constant) | `RateLimiterExtensions.cs` policy registration, `RateLimitingOptions.cs` config class, `appsettings.json` section, `[EnableRateLimiting]` attribute on controllers |
| **`RateLimitingOptions.cs`** (add/rename option class) | `RateLimiterExtensions.cs`, `appsettings.json`, `appsettings.Development.json` |
@@ -109,7 +127,7 @@ Quick-reference for "when you change X, also update Y" and "where does X live?"
| **Connection string config** (change format/name) | Verify `MyProject.AppHost/Program.cs` environment variable mapping still works |
| **`MyProject.ServiceDefaults/Extensions.cs`** | All projects referencing ServiceDefaults, `Program.cs` `AddServiceDefaults()` call |
| **`MyProject.AppHost/Program.cs`** | Verify resource names match `ConnectionStrings:*` and `WithEnvironment` keys match `appsettings.json` option paths |
-| **`ProblemDetailsAuthorizationHandler`** | `ProblemDetails` shape, `ErrorMessages.Auth` constants, `Program.cs` registration |
+| **`ProblemDetailsAuthorizationHandler`** | `ProblemFactory.CreateProblemDetails`, `ErrorMessages.Auth` entries, `Program.cs` registration |
| **OpenAPI transformers** | Regenerate types to verify; check Scalar UI |
# @feature captcha
| **`CaptchaOptions`** (Infrastructure - Captcha config) | `appsettings.json`, `appsettings.Development.json`, `appsettings.Testing.json`, `TurnstileCaptchaService`, `ServiceCollectionExtensions` |
@@ -126,7 +144,7 @@ Files that are frequently referenced in impact tables above. For anything not li
```
src/backend/MyProject.{Layer}/
- Shared: Result.cs, ErrorType.cs, ErrorMessages.cs, PhoneNumberHelper.cs
+ Shared: Result.cs, Error.cs, ErrorType.cs, ErrorMessages.cs, PhoneNumberHelper.cs
Domain: Entities/{Entity}.cs
Application: Features/{Feature}/I{Feature}Service.cs
Features/{Feature}/Dtos/{Operation}Input.cs, {Entity}Output.cs
@@ -154,7 +172,8 @@ src/backend/MyProject.{Layer}/
Authorization/ProblemDetailsAuthorizationHandler.cs
# @end
Routing/{Name}RouteConstraint.cs
- Shared/RateLimitPolicies.cs
+ Shared/RateLimitPolicies.cs, ProblemFactory.cs
+ Features/OpenApi/Transformers/{Name}SchemaTransformer.cs
Program.cs
```
@@ -172,8 +191,17 @@ src/backend/MyProject.Application/Features/Email/
src/backend/MyProject.Infrastructure/Features/Email/
Services/FluidEmailTemplateRenderer.cs Fluid-based renderer (singleton, cached)
Services/TemplatedEmailSender.cs Render+send wrapper (swallows failures)
+# @feature jobs
+ Services/BackgroundEmailService.cs Queues EmailDeliveryJob (Enabled + JobScheduling:Enabled)
+ Services/SmtpEmailService.cs MailKit SMTP sender (direct when jobs disabled; job executor otherwise)
+# @end
+# @feature !jobs
Services/SmtpEmailService.cs MailKit SMTP sender (when Enabled)
+# @end
Services/NoOpEmailService.cs Dev/test no-op sender (when disabled)
+# @feature jobs
+ Jobs/EmailDeliveryJob.cs Hangfire job: SMTP send with [AutomaticRetry] backoff
+# @end
Templates/_base.liquid Shared HTML email layout (header, card, footer)
Templates/{name}.liquid HTML body fragment
Templates/{name}.text.liquid Plain text variant (optional)
@@ -219,6 +247,9 @@ src/backend/tests/
MyProject.Api.Tests/
Fixtures/CustomWebApplicationFactory.cs WebApplicationFactory config
Fixtures/TestAuthHandler.cs Fake auth handler
+ Fixtures/ProblemDetailsAssert.cs Shared ProblemDetails (status/detail/code) assertions
+ Shared/ProblemFactoryTests.cs ProblemFactory + code fallback tests
+ Middlewares/ProblemDetailsCodeTests.cs End-to-end `code` presence across the pipeline
Contracts/ResponseContracts.cs Frozen response shapes for contract testing
Controllers/{Controller}Tests.cs HTTP integration tests
Validators/{Validator}Tests.cs FluentValidation tests
@@ -234,7 +265,7 @@ src/backend/tests/
|---|---|
| `src/backend/MyProject.WebApi/Program.cs` | DI wiring, middleware pipeline |
| `src/backend/MyProject.Infrastructure/Persistence/MyProjectDbContext.cs` | DbSets, migrations |
-| `src/backend/MyProject.Shared/ErrorMessages.cs` | All static error strings |
+| `src/backend/MyProject.Shared/ErrorMessages.cs` | All client-facing errors (`Error` = stable snake_case code + message) |
# @feature auth
| `src/backend/MyProject.Application/Identity/Constants/AppRoles.cs` | Role definitions |
| `src/backend/MyProject.Application/Identity/Constants/AppPermissions.cs` | Permission definitions (reflection-discovered) |
diff --git a/templates/src/backend/MyProject.Infrastructure/Features/Admin/Services/AdminService.cs b/templates/src/backend/MyProject.Infrastructure/Features/Admin/Services/AdminService.cs
index 0862786..9d00bd3 100644
--- a/templates/src/backend/MyProject.Infrastructure/Features/Admin/Services/AdminService.cs
+++ b/templates/src/backend/MyProject.Infrastructure/Features/Admin/Services/AdminService.cs
@@ -626,7 +626,7 @@ await auditService.LogAsync(AuditActions.AdminCreateUser, userId: callerUserId,
///
/// Verifies that the caller has a strictly higher role rank than the target user.
- /// Returns if the hierarchy check fails.
+ /// Returns if the hierarchy check fails.
///
private async Task EnforceHierarchyAsync(Guid callerUserId, ApplicationUser targetUser)
{
diff --git a/templates/src/backend/MyProject.Infrastructure/Features/Authentication/Services/AuthenticationService.cs b/templates/src/backend/MyProject.Infrastructure/Features/Authentication/Services/AuthenticationService.cs
index 920ae3a..a14c835 100644
--- a/templates/src/backend/MyProject.Infrastructure/Features/Authentication/Services/AuthenticationService.cs
+++ b/templates/src/backend/MyProject.Infrastructure/Features/Authentication/Services/AuthenticationService.cs
@@ -230,7 +230,7 @@ public async Task> Register(RegisterInput input, CancellationToken
if (!result.Succeeded)
{
var errors = string.Join(", ", result.Errors.Select(e => e.Description));
- return Result.Failure(errors);
+ return Result.Failure(ErrorMessages.Auth.RegistrationInvalid with { Message = errors });
}
var roleResult = await userManager.AddToRoleAsync(user, AppRoles.User);
@@ -300,7 +300,7 @@ public async Task ChangePasswordAsync(ChangePasswordInput input, Cancell
if (!changeResult.Succeeded)
{
var errors = string.Join(", ", changeResult.Errors.Select(e => e.Description));
- return Result.Failure(errors);
+ return Result.Failure(ErrorMessages.Auth.PasswordPolicyViolation with { Message = errors });
}
await tokenSessionService.RevokeUserTokensAsync(userId.Value, cancellationToken);
@@ -372,7 +372,7 @@ public async Task ResetPasswordAsync(ResetPasswordInput input, Cancellat
return Result.Failure(ErrorMessages.Auth.ResetPasswordTokenInvalid);
}
- return Result.Failure(string.Join(" ", errors));
+ return Result.Failure(ErrorMessages.Auth.PasswordPolicyViolation with { Message = string.Join(" ", errors) });
}
emailToken.IsUsed = true;
diff --git a/templates/src/backend/MyProject.Infrastructure/Features/Authentication/Services/TokenSessionService.cs b/templates/src/backend/MyProject.Infrastructure/Features/Authentication/Services/TokenSessionService.cs
index 9cd48c4..ed5c89c 100644
--- a/templates/src/backend/MyProject.Infrastructure/Features/Authentication/Services/TokenSessionService.cs
+++ b/templates/src/backend/MyProject.Infrastructure/Features/Authentication/Services/TokenSessionService.cs
@@ -195,13 +195,13 @@ public async Task> RefreshTokenAsync(
return Result.Success(
new AuthenticationOutput(AccessToken: newAccessToken, RefreshToken: newRefreshTokenString));
- Result Fail(string message)
+ Result Fail(Error error)
{
if (useCookies)
{
DeleteAuthCookies();
}
- return Result.Failure(message, ErrorType.Unauthorized);
+ return Result.Failure(error, ErrorType.Unauthorized);
}
}
diff --git a/templates/src/backend/MyProject.Infrastructure/Features/Email/Extensions/ServiceCollectionExtensions.cs b/templates/src/backend/MyProject.Infrastructure/Features/Email/Extensions/ServiceCollectionExtensions.cs
index 90e8075..ea0b454 100644
--- a/templates/src/backend/MyProject.Infrastructure/Features/Email/Extensions/ServiceCollectionExtensions.cs
+++ b/templates/src/backend/MyProject.Infrastructure/Features/Email/Extensions/ServiceCollectionExtensions.cs
@@ -1,8 +1,14 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using MyProject.Application.Features.Email;
+// @feature jobs
+using MyProject.Infrastructure.Features.Email.Jobs;
+// @end
using MyProject.Infrastructure.Features.Email.Options;
using MyProject.Infrastructure.Features.Email.Services;
+// @feature jobs
+using MyProject.Infrastructure.Features.Jobs.Options;
+// @end
namespace MyProject.Infrastructure.Features.Email.Extensions;
@@ -15,8 +21,19 @@ public static class ServiceCollectionExtensions
{
///
/// Registers email options, the template rendering pipeline, and the email service.
+ // @feature jobs
+ ///
+ /// - Email:Enabled and JobScheduling:Enabled both true:
+ /// queues sends as Hangfire jobs executed by
+ /// (SMTP with automatic retries).
+ /// - Email:Enabled only: sends inline.
+ /// - Email:Enabled is false: (log only).
+ ///
+ // @end
+ // @feature !jobs
/// When Email:Enabled is true, registers ;
/// otherwise registers (log only).
+ // @end
///
/// The application configuration for reading email options.
/// The service collection for chaining.
@@ -31,7 +48,22 @@ public IServiceCollection AddEmailServices(IConfiguration configuration)
.GetSection(EmailOptions.SectionName)
.Get() ?? new EmailOptions();
+ // @feature jobs
+ var jobSchedulingEnabled = configuration
+ .GetSection(JobSchedulingOptions.SectionName)
+ .Get()?.Enabled ?? new JobSchedulingOptions().Enabled;
+
+ if (options.Enabled && jobSchedulingEnabled)
+ {
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ }
+ else if (options.Enabled)
+ // @end
+ // @feature !jobs
if (options.Enabled)
+ // @end
{
services.AddScoped();
}
diff --git a/templates/src/backend/MyProject.Infrastructure/Features/Email/Jobs/EmailDeliveryJob.cs b/templates/src/backend/MyProject.Infrastructure/Features/Email/Jobs/EmailDeliveryJob.cs
new file mode 100644
index 0000000..f7b41dc
--- /dev/null
+++ b/templates/src/backend/MyProject.Infrastructure/Features/Email/Jobs/EmailDeliveryJob.cs
@@ -0,0 +1,40 @@
+// @feature jobs
+using Hangfire;
+using MyProject.Application.Features.Email;
+using MyProject.Infrastructure.Features.Email.Services;
+
+namespace MyProject.Infrastructure.Features.Email.Jobs;
+
+///
+/// Hangfire job that delivers a single rendered email via SMTP.
+/// Enqueued by so that transient SMTP failures
+/// are retried automatically instead of being silently lost. Failed deliveries remain
+/// in Hangfire storage (visible in the development dashboard), where they can be retried manually.
+///
+/// The rendered (including any verification or reset links) is persisted
+/// as the job payload until the job expires, so Hangfire storage must be treated as sensitive data.
+///
+///
+internal sealed class EmailDeliveryJob(SmtpEmailService smtpEmailService)
+{
+ ///
+ /// Maximum number of automatic retries after the first failed attempt.
+ ///
+ public const int RetryAttempts = 5;
+
+ ///
+ /// Sends the given message via SMTP. Any delivery exception is propagated so Hangfire
+ /// records the failure (with the exception details) and schedules the next retry.
+ ///
+ /// is JSON-serialized by Hangfire and persisted with the job;
+ /// the is injected by Hangfire and signals server shutdown.
+ ///
+ ///
+ /// The rendered email message to deliver.
+ /// Hangfire-injected token signalling server shutdown.
+ [AutomaticRetry(Attempts = RetryAttempts, DelaysInSeconds = [30, 120, 600, 1800, 3600],
+ OnAttemptsExceeded = AttemptsExceededAction.Fail)]
+ public Task ExecuteAsync(EmailMessage message, CancellationToken cancellationToken) =>
+ smtpEmailService.SendEmailAsync(message, cancellationToken);
+}
+// @end
diff --git a/templates/src/backend/MyProject.Infrastructure/Features/Email/Services/BackgroundEmailService.cs b/templates/src/backend/MyProject.Infrastructure/Features/Email/Services/BackgroundEmailService.cs
new file mode 100644
index 0000000..da4fb75
--- /dev/null
+++ b/templates/src/backend/MyProject.Infrastructure/Features/Email/Services/BackgroundEmailService.cs
@@ -0,0 +1,33 @@
+// @feature jobs
+using Hangfire;
+using Microsoft.Extensions.Logging;
+using MyProject.Application.Features.Email;
+using MyProject.Infrastructure.Features.Email.Jobs;
+
+namespace MyProject.Infrastructure.Features.Email.Services;
+
+///
+/// Queues emails as Hangfire background jobs instead of sending them inline.
+/// Delivery is performed by with automatic retries,
+/// so a transient SMTP outage delays the email rather than losing it.
+/// Registered when both Email:Enabled and JobScheduling:Enabled are true.
+///
+internal class BackgroundEmailService(
+ IBackgroundJobClient backgroundJobClient,
+ ILogger logger) : IEmailService
+{
+ ///
+ public Task SendEmailAsync(EmailMessage message, CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var jobId = backgroundJobClient.Enqueue(job =>
+ job.ExecuteAsync(message, CancellationToken.None));
+
+ logger.LogInformation("Email to {To} queued as job {JobId} | Subject: {Subject}",
+ message.To, jobId, message.Subject);
+
+ return Task.CompletedTask;
+ }
+}
+// @end
diff --git a/templates/src/backend/MyProject.Infrastructure/Features/Email/Services/TemplatedEmailSender.cs b/templates/src/backend/MyProject.Infrastructure/Features/Email/Services/TemplatedEmailSender.cs
index c1a2c07..0af2916 100644
--- a/templates/src/backend/MyProject.Infrastructure/Features/Email/Services/TemplatedEmailSender.cs
+++ b/templates/src/backend/MyProject.Infrastructure/Features/Email/Services/TemplatedEmailSender.cs
@@ -4,9 +4,10 @@
namespace MyProject.Infrastructure.Features.Email.Services;
///
-/// Renders an email template and sends the result, swallowing both rendering
-/// and delivery failures. Transient provider outages (quota, auth, network)
-/// and template errors are logged but never propagate to the caller.
+/// Renders an email template and hands the result to , swallowing both
+/// rendering and hand-off failures. Depending on configuration the hand-off is a Hangfire enqueue
+/// (, retried by EmailDeliveryJob) or a direct SMTP send;
+/// either way, failures are logged but never propagate to the caller.
/// is re-thrown to respect cooperative cancellation.
///
internal class TemplatedEmailSender(
diff --git a/templates/src/backend/MyProject.Infrastructure/Features/FileStorage/Services/S3FileStorageService.cs b/templates/src/backend/MyProject.Infrastructure/Features/FileStorage/Services/S3FileStorageService.cs
index f545956..04eb5f3 100644
--- a/templates/src/backend/MyProject.Infrastructure/Features/FileStorage/Services/S3FileStorageService.cs
+++ b/templates/src/backend/MyProject.Infrastructure/Features/FileStorage/Services/S3FileStorageService.cs
@@ -47,12 +47,12 @@ public async Task UploadAsync(string key, byte[] data, string contentTyp
catch (AmazonS3Exception ex)
{
logger.LogError(ex, "Failed to upload object '{Key}' to bucket '{Bucket}'", key, _bucketName);
- return Result.Failure("Failed to upload file to storage.");
+ return Result.Failure(ErrorMessages.FileStorage.UploadFailed);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogError(ex, "Unexpected error uploading object '{Key}' to bucket '{Bucket}'", key, _bucketName);
- return Result.Failure("Failed to upload file to storage.");
+ return Result.Failure(ErrorMessages.FileStorage.UploadFailed);
}
}
@@ -76,17 +76,17 @@ public async Task> DownloadAsync(string key, Cancella
}
catch (AmazonS3Exception ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
{
- return Result.Failure("File not found.", ErrorType.NotFound);
+ return Result.Failure(ErrorMessages.FileStorage.NotFound, ErrorType.NotFound);
}
catch (AmazonS3Exception ex)
{
logger.LogError(ex, "Failed to download object '{Key}' from bucket '{Bucket}'", key, _bucketName);
- return Result.Failure("Failed to retrieve file from storage.");
+ return Result.Failure(ErrorMessages.FileStorage.DownloadFailed);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogError(ex, "Unexpected error downloading object '{Key}' from bucket '{Bucket}'", key, _bucketName);
- return Result.Failure("Failed to retrieve file from storage.");
+ return Result.Failure(ErrorMessages.FileStorage.DownloadFailed);
}
}
@@ -109,12 +109,12 @@ public async Task DeleteAsync(string key, CancellationToken ct)
catch (AmazonS3Exception ex)
{
logger.LogError(ex, "Failed to delete object '{Key}' from bucket '{Bucket}'", key, _bucketName);
- return Result.Failure("Failed to delete file from storage.");
+ return Result.Failure(ErrorMessages.FileStorage.DeleteFailed);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogError(ex, "Unexpected error deleting object '{Key}' from bucket '{Bucket}'", key, _bucketName);
- return Result.Failure("Failed to delete file from storage.");
+ return Result.Failure(ErrorMessages.FileStorage.DeleteFailed);
}
}
diff --git a/templates/src/backend/MyProject.Infrastructure/Identity/Services/UserService.cs b/templates/src/backend/MyProject.Infrastructure/Identity/Services/UserService.cs
index d5e0c92..4bab9c4 100644
--- a/templates/src/backend/MyProject.Infrastructure/Identity/Services/UserService.cs
+++ b/templates/src/backend/MyProject.Infrastructure/Identity/Services/UserService.cs
@@ -408,7 +408,7 @@ private async Task DeleteUser(ApplicationUser user)
{
logger.LogWarning("DeleteAsync failed for user '{UserId}': {Errors}",
user.Id, string.Join(", ", result.Errors.Select(e => e.Description)));
- throw new InvalidOperationException(ErrorMessages.User.DeleteFailed);
+ throw new InvalidOperationException(ErrorMessages.User.DeleteFailed.Message);
}
}
diff --git a/templates/src/backend/MyProject.Infrastructure/Persistence/Extensions/PaginationExtensions.cs b/templates/src/backend/MyProject.Infrastructure/Persistence/Extensions/PaginationExtensions.cs
index 30312a0..53387de 100644
--- a/templates/src/backend/MyProject.Infrastructure/Persistence/Extensions/PaginationExtensions.cs
+++ b/templates/src/backend/MyProject.Infrastructure/Persistence/Extensions/PaginationExtensions.cs
@@ -21,11 +21,11 @@ public static class PaginationExtensions
/// Thrown when page number is less than or equal to 0, or when page size is less than or equal to 0
public static IQueryable Paginate(this IQueryable ts, int pageNumber, int pageSize)
{
- if (pageNumber <= 0) throw new PaginationException(nameof(pageNumber), ErrorMessages.Pagination.InvalidPage);
+ if (pageNumber <= 0) throw new PaginationException(nameof(pageNumber), ErrorMessages.Pagination.InvalidPage.Message);
pageSize = pageSize switch
{
- <= 0 => throw new PaginationException(nameof(pageSize), ErrorMessages.Pagination.InvalidPageSize),
+ <= 0 => throw new PaginationException(nameof(pageSize), ErrorMessages.Pagination.InvalidPageSize.Message),
> MaxPageSize => MaxPageSize,
_ => pageSize
};
diff --git a/templates/src/backend/MyProject.Shared/Error.cs b/templates/src/backend/MyProject.Shared/Error.cs
new file mode 100644
index 0000000..6f9a058
--- /dev/null
+++ b/templates/src/backend/MyProject.Shared/Error.cs
@@ -0,0 +1,22 @@
+namespace MyProject.Shared;
+
+///
+/// A client-facing error consisting of a stable, machine-readable and a
+/// human-readable .
+///
+///
+///
+/// Instances are declared once in and passed to Result.Failure().
+/// The code surfaces as the code extension of every ProblemDetails response so that
+/// clients can branch on it (or use it as a translation key) instead of matching English text.
+///
+///
+/// Codes are snake_case and derived from the declaring location: {NestedClass}_{FieldName}
+/// (for example ErrorMessages.ExternalAuth.StateExpired is external_auth_state_expired).
+/// Treat codes as a public contract - renaming one is a breaking change for API consumers.
+///
+/// Pattern documented in .claude/skills/backend-conventions/SKILL.md.
+///
+/// The stable, snake_case, machine-readable identifier of the error condition.
+/// The human-readable, client-facing description of the error.
+public sealed record Error(string Code, string Message);
diff --git a/templates/src/backend/MyProject.Shared/ErrorMessages.cs b/templates/src/backend/MyProject.Shared/ErrorMessages.cs
index b0433cc..ffda4fe 100644
--- a/templates/src/backend/MyProject.Shared/ErrorMessages.cs
+++ b/templates/src/backend/MyProject.Shared/ErrorMessages.cs
@@ -1,13 +1,20 @@
namespace MyProject.Shared;
///
-/// User-facing error messages organized by domain area.
-/// Constants are used in Result.Failure() calls so that messages remain consistent,
-/// greppable, and easy to extract into translation keys later.
+/// User-facing errors organized by domain area. Each entry is an pairing a stable,
+/// machine-readable code with a human-readable message. Entries are used in Result.Failure() calls
+/// so that messages remain consistent, greppable, and translatable by clients via the code.
///
-/// All client-facing messages must be static constants - never interpolate runtime values
+/// Codes follow {nested_class}_{field_name} in snake_case and are verified by
+/// ErrorMessagesTests. Renaming a code is a breaking change for API consumers.
+///
+///
+/// All client-facing messages must be static - never interpolate runtime values
/// (role names, user IDs, framework error descriptions) into error responses.
-/// Log runtime details server-side via ILogger instead.
+/// Log runtime details server-side via ILogger instead. The only exceptions are
+/// entries explicitly documented as carrying a dynamic message (password policy feedback,
+/// rate-limit retry hints); those keep their stable code and override
+/// with a with expression.
///
///
public static class ErrorMessages
@@ -18,25 +25,37 @@ public static class ErrorMessages
///
public static class Auth
{
- public const string LoginInvalidCredentials = "Invalid username or password.";
- public const string LoginAccountLocked = "Account is temporarily locked. Please try again later or contact an administrator.";
- public const string RegisterRoleAssignFailed = "Account was created but role assignment failed. Please contact an administrator.";
- public const string TokenMissing = "Refresh token is missing.";
- public const string TokenNotFound = "Refresh token not found.";
- public const string TokenInvalidated = "Refresh token has been invalidated.";
- public const string TokenReused = "Invalid refresh token.";
- public const string TokenExpired = "Refresh token has expired.";
- public const string TokenUserNotFound = "Token owner not found.";
- public const string NotAuthenticated = "User is not authenticated.";
- public const string InsufficientPermissions = "You do not have the required permissions for this action.";
- public const string UserNotFound = "User not found.";
- public const string PasswordIncorrect = "Current password is incorrect.";
- public const string ResetPasswordFailed = "Password reset failed. The link may have expired or already been used.";
- public const string ResetPasswordTokenInvalid = "Invalid or expired password reset token.";
- public const string EmailVerificationFailed = "Email verification failed. The link may have expired or already been used.";
- public const string EmailAlreadyVerified = "Email address is already verified.";
- public const string PasswordSameAsCurrent = "New password must be different from your current password.";
- public const string CaptchaInvalid = "CAPTCHA verification failed. Please try again.";
+ public static readonly Error LoginInvalidCredentials = new("auth_login_invalid_credentials", "Invalid username or password.");
+ public static readonly Error LoginAccountLocked = new("auth_login_account_locked", "Account is temporarily locked. Please try again later or contact an administrator.");
+ public static readonly Error RegisterRoleAssignFailed = new("auth_register_role_assign_failed", "Account was created but role assignment failed. Please contact an administrator.");
+ public static readonly Error TokenMissing = new("auth_token_missing", "Refresh token is missing.");
+ public static readonly Error TokenNotFound = new("auth_token_not_found", "Refresh token not found.");
+ public static readonly Error TokenInvalidated = new("auth_token_invalidated", "Refresh token has been invalidated.");
+ public static readonly Error TokenReused = new("auth_token_reused", "Invalid refresh token.");
+ public static readonly Error TokenExpired = new("auth_token_expired", "Refresh token has expired.");
+ public static readonly Error TokenUserNotFound = new("auth_token_user_not_found", "Token owner not found.");
+ public static readonly Error NotAuthenticated = new("auth_not_authenticated", "User is not authenticated.");
+ public static readonly Error InsufficientPermissions = new("auth_insufficient_permissions", "You do not have the required permissions for this action.");
+ public static readonly Error UserNotFound = new("auth_user_not_found", "User not found.");
+ public static readonly Error PasswordIncorrect = new("auth_password_incorrect", "Current password is incorrect.");
+ public static readonly Error ResetPasswordFailed = new("auth_reset_password_failed", "Password reset failed. The link may have expired or already been used.");
+ public static readonly Error ResetPasswordTokenInvalid = new("auth_reset_password_token_invalid", "Invalid or expired password reset token.");
+ public static readonly Error EmailVerificationFailed = new("auth_email_verification_failed", "Email verification failed. The link may have expired or already been used.");
+ public static readonly Error EmailAlreadyVerified = new("auth_email_already_verified", "Email address is already verified.");
+ public static readonly Error PasswordSameAsCurrent = new("auth_password_same_as_current", "New password must be different from your current password.");
+ public static readonly Error CaptchaInvalid = new("auth_captcha_invalid", "CAPTCHA verification failed. Please try again.");
+
+ ///
+ /// Registration rejected by ASP.NET Identity (password policy, duplicate email). The message is
+ /// overridden with the Identity error descriptions so users get actionable feedback.
+ ///
+ public static readonly Error RegistrationInvalid = new("auth_registration_invalid", "Registration failed. Please check the provided details.");
+
+ ///
+ /// New password rejected by the password policy. The message is overridden with the
+ /// Identity error descriptions so users get actionable feedback.
+ ///
+ public static readonly Error PasswordPolicyViolation = new("auth_password_policy_violation", "The new password does not meet the password requirements.");
}
///
@@ -44,15 +63,15 @@ public static class Auth
///
public static class TwoFactor
{
- public const string SetupFailed = "Failed to set up two-factor authentication.";
- public const string VerificationFailed = "The verification code is invalid. Please try again.";
- public const string AlreadyEnabled = "Two-factor authentication is already enabled.";
- public const string NotEnabled = "Two-factor authentication is not enabled.";
- public const string DisableFailed = "Failed to disable two-factor authentication.";
- public const string ChallengeNotFound = "Two-factor challenge not found or expired.";
- public const string ChallengeLocked = "Too many failed attempts. Please log in again.";
- public const string RecoveryCodeInvalid = "The recovery code is invalid.";
- public const string InvalidCode = "The two-factor code is invalid.";
+ public static readonly Error SetupFailed = new("two_factor_setup_failed", "Failed to set up two-factor authentication.");
+ public static readonly Error VerificationFailed = new("two_factor_verification_failed", "The verification code is invalid. Please try again.");
+ public static readonly Error AlreadyEnabled = new("two_factor_already_enabled", "Two-factor authentication is already enabled.");
+ public static readonly Error NotEnabled = new("two_factor_not_enabled", "Two-factor authentication is not enabled.");
+ public static readonly Error DisableFailed = new("two_factor_disable_failed", "Failed to disable two-factor authentication.");
+ public static readonly Error ChallengeNotFound = new("two_factor_challenge_not_found", "Two-factor challenge not found or expired.");
+ public static readonly Error ChallengeLocked = new("two_factor_challenge_locked", "Too many failed attempts. Please log in again.");
+ public static readonly Error RecoveryCodeInvalid = new("two_factor_recovery_code_invalid", "The recovery code is invalid.");
+ public static readonly Error InvalidCode = new("two_factor_invalid_code", "The two-factor code is invalid.");
}
///
@@ -60,13 +79,13 @@ public static class TwoFactor
///
public static class User
{
- public const string NotAuthenticated = "User is not authenticated.";
- public const string NotFound = "User not found.";
- public const string DeleteInvalidPassword = "Invalid password.";
- public const string PhoneNumberTaken = "This phone number is already in use.";
- public const string UpdateFailed = "Failed to update profile.";
- public const string DeleteFailed = "Failed to delete account.";
- public const string LastSuperuserCannotDelete = "Cannot delete your account while you are the last superuser.";
+ public static readonly Error NotAuthenticated = new("user_not_authenticated", "User is not authenticated.");
+ public static readonly Error NotFound = new("user_not_found", "User not found.");
+ public static readonly Error DeleteInvalidPassword = new("user_delete_invalid_password", "Invalid password.");
+ public static readonly Error PhoneNumberTaken = new("user_phone_number_taken", "This phone number is already in use.");
+ public static readonly Error UpdateFailed = new("user_update_failed", "Failed to update profile.");
+ public static readonly Error DeleteFailed = new("user_delete_failed", "Failed to delete account.");
+ public static readonly Error LastSuperuserCannotDelete = new("user_last_superuser_cannot_delete", "Cannot delete your account while you are the last superuser.");
}
// @end
@@ -76,31 +95,31 @@ public static class User
///
public static class Admin
{
- public const string UserNotFound = "User not found.";
- public const string HierarchyInsufficient = "You do not have sufficient privileges to manage this user.";
- public const string RoleAssignAboveRank = "Cannot assign a role at or above your own rank.";
- public const string RoleRemoveAboveRank = "Cannot remove a role at or above your own rank.";
- public const string RoleSelfRemove = "Cannot remove a role from your own account.";
- public const string LockSelfAction = "Cannot lock your own account.";
- public const string DeleteSelfAction = "Cannot delete your own account.";
- public const string EmailVerificationRequired = "User must have a verified email address before being assigned this role.";
- public const string EmailAlreadyRegistered = "A user with this email address already exists.";
- public const string RoleAssignEscalation = "Cannot assign a role that grants permissions you do not hold.";
- public const string RoleNotFound = "Role not found.";
- public const string RoleAlreadyAssigned = "User already has this role.";
- public const string RoleNotAssigned = "User does not have this role.";
- public const string LastRoleHolder = "Cannot remove this role - this is the last user holding it.";
- public const string RoleAssignFailed = "Failed to assign role.";
- public const string RoleRemoveFailed = "Failed to remove role.";
- public const string LockFailed = "Failed to lock user account.";
- public const string UnlockFailed = "Failed to unlock user account.";
- public const string DeleteFailed = "Failed to delete user account.";
- public const string EmailVerificationFailed = "Failed to verify email address.";
- public const string CreateUserFailed = "Failed to create user account.";
- public const string LastSuperuserCannotDelete = "Cannot delete this user - they are the last superuser.";
- public const string TwoFactorNotEnabled = "Two-factor authentication is not enabled for this user.";
- public const string DisableTwoFactorSelfAction = "You cannot disable your own two-factor authentication from the admin panel.";
- public const string DisableTwoFactorFailed = "Failed to disable two-factor authentication.";
+ public static readonly Error UserNotFound = new("admin_user_not_found", "User not found.");
+ public static readonly Error HierarchyInsufficient = new("admin_hierarchy_insufficient", "You do not have sufficient privileges to manage this user.");
+ public static readonly Error RoleAssignAboveRank = new("admin_role_assign_above_rank", "Cannot assign a role at or above your own rank.");
+ public static readonly Error RoleRemoveAboveRank = new("admin_role_remove_above_rank", "Cannot remove a role at or above your own rank.");
+ public static readonly Error RoleSelfRemove = new("admin_role_self_remove", "Cannot remove a role from your own account.");
+ public static readonly Error LockSelfAction = new("admin_lock_self_action", "Cannot lock your own account.");
+ public static readonly Error DeleteSelfAction = new("admin_delete_self_action", "Cannot delete your own account.");
+ public static readonly Error EmailVerificationRequired = new("admin_email_verification_required", "User must have a verified email address before being assigned this role.");
+ public static readonly Error EmailAlreadyRegistered = new("admin_email_already_registered", "A user with this email address already exists.");
+ public static readonly Error RoleAssignEscalation = new("admin_role_assign_escalation", "Cannot assign a role that grants permissions you do not hold.");
+ public static readonly Error RoleNotFound = new("admin_role_not_found", "Role not found.");
+ public static readonly Error RoleAlreadyAssigned = new("admin_role_already_assigned", "User already has this role.");
+ public static readonly Error RoleNotAssigned = new("admin_role_not_assigned", "User does not have this role.");
+ public static readonly Error LastRoleHolder = new("admin_last_role_holder", "Cannot remove this role - this is the last user holding it.");
+ public static readonly Error RoleAssignFailed = new("admin_role_assign_failed", "Failed to assign role.");
+ public static readonly Error RoleRemoveFailed = new("admin_role_remove_failed", "Failed to remove role.");
+ public static readonly Error LockFailed = new("admin_lock_failed", "Failed to lock user account.");
+ public static readonly Error UnlockFailed = new("admin_unlock_failed", "Failed to unlock user account.");
+ public static readonly Error DeleteFailed = new("admin_delete_failed", "Failed to delete user account.");
+ public static readonly Error EmailVerificationFailed = new("admin_email_verification_failed", "Failed to verify email address.");
+ public static readonly Error CreateUserFailed = new("admin_create_user_failed", "Failed to create user account.");
+ public static readonly Error LastSuperuserCannotDelete = new("admin_last_superuser_cannot_delete", "Cannot delete this user - they are the last superuser.");
+ public static readonly Error TwoFactorNotEnabled = new("admin_two_factor_not_enabled", "Two-factor authentication is not enabled for this user.");
+ public static readonly Error DisableTwoFactorSelfAction = new("admin_disable_two_factor_self_action", "You cannot disable your own two-factor authentication from the admin panel.");
+ public static readonly Error DisableTwoFactorFailed = new("admin_disable_two_factor_failed", "Failed to disable two-factor authentication.");
}
///
@@ -108,18 +127,18 @@ public static class Admin
///
public static class Roles
{
- public const string SystemRoleCannotBeDeleted = "System roles cannot be deleted.";
- public const string SystemRoleCannotBeRenamed = "System roles cannot be renamed.";
- public const string RoleNotFound = "Role not found.";
- public const string RoleNameTaken = "A role with this name already exists.";
- public const string RoleHasUsers = "Cannot delete a role that has users assigned to it.";
- public const string InvalidPermission = "One or more permission values are invalid.";
- public const string SystemRoleNameReserved = "This name is reserved for a system role.";
- public const string SuperuserPermissionsFixed = "Superuser permissions cannot be modified.";
- public const string CannotGrantUnheldPermission = "Cannot grant permissions that you do not hold.";
- public const string CreateFailed = "Failed to create role.";
- public const string UpdateFailed = "Failed to update role.";
- public const string DeleteFailed = "Failed to delete role.";
+ public static readonly Error SystemRoleCannotBeDeleted = new("roles_system_role_cannot_be_deleted", "System roles cannot be deleted.");
+ public static readonly Error SystemRoleCannotBeRenamed = new("roles_system_role_cannot_be_renamed", "System roles cannot be renamed.");
+ public static readonly Error RoleNotFound = new("roles_role_not_found", "Role not found.");
+ public static readonly Error RoleNameTaken = new("roles_role_name_taken", "A role with this name already exists.");
+ public static readonly Error RoleHasUsers = new("roles_role_has_users", "Cannot delete a role that has users assigned to it.");
+ public static readonly Error InvalidPermission = new("roles_invalid_permission", "One or more permission values are invalid.");
+ public static readonly Error SystemRoleNameReserved = new("roles_system_role_name_reserved", "This name is reserved for a system role.");
+ public static readonly Error SuperuserPermissionsFixed = new("roles_superuser_permissions_fixed", "Superuser permissions cannot be modified.");
+ public static readonly Error CannotGrantUnheldPermission = new("roles_cannot_grant_unheld_permission", "Cannot grant permissions that you do not hold.");
+ public static readonly Error CreateFailed = new("roles_create_failed", "Failed to create role.");
+ public static readonly Error UpdateFailed = new("roles_update_failed", "Failed to update role.");
+ public static readonly Error DeleteFailed = new("roles_delete_failed", "Failed to delete role.");
}
// @end
@@ -128,8 +147,8 @@ public static class Roles
///
public static class Pagination
{
- public const string InvalidPage = "Page number must be positive.";
- public const string InvalidPageSize = "Page size must be positive.";
+ public static readonly Error InvalidPage = new("pagination_invalid_page", "Page number must be positive.");
+ public static readonly Error InvalidPageSize = new("pagination_invalid_page_size", "Page size must be positive.");
}
///
@@ -137,7 +156,13 @@ public static class Pagination
///
public static class Server
{
- public const string InternalError = "An internal error occurred.";
+ public static readonly Error InternalError = new("server_internal_error", "An internal error occurred.");
+
+ ///
+ /// Request rejected by the rate limiter. The message is overridden with the retry hint
+ /// (seconds until the window resets).
+ ///
+ public static readonly Error TooManyRequests = new("server_too_many_requests", "Too many requests. Please try again later.");
}
// @feature jobs
@@ -146,9 +171,9 @@ public static class Server
///
public static class Jobs
{
- public const string NotFound = "Job not found.";
- public const string TriggerFailed = "Failed to trigger job.";
- public const string RestoreFailed = "Failed to restore jobs.";
+ public static readonly Error NotFound = new("jobs_not_found", "Job not found.");
+ public static readonly Error TriggerFailed = new("jobs_trigger_failed", "Failed to trigger job.");
+ public static readonly Error RestoreFailed = new("jobs_restore_failed", "Failed to restore jobs.");
}
// @end
@@ -157,7 +182,7 @@ public static class Jobs
///
public static class Security
{
- public const string CrossOriginRequestBlocked = "Cross-origin requests are not allowed.";
+ public static readonly Error CrossOriginRequestBlocked = new("security_cross_origin_request_blocked", "Cross-origin requests are not allowed.");
}
// @feature avatars
@@ -166,10 +191,23 @@ public static class Security
///
public static class Avatar
{
- public const string FileTooLarge = "The file exceeds the maximum allowed size of 5 MB.";
- public const string UnsupportedFormat = "Unsupported image format. Allowed formats: JPEG, PNG, WebP, GIF.";
- public const string ProcessingFailed = "Failed to process the avatar image.";
- public const string NotFound = "Avatar not found.";
+ public static readonly Error FileTooLarge = new("avatar_file_too_large", "The file exceeds the maximum allowed size of 5 MB.");
+ public static readonly Error UnsupportedFormat = new("avatar_unsupported_format", "Unsupported image format. Allowed formats: JPEG, PNG, WebP, GIF.");
+ public static readonly Error ProcessingFailed = new("avatar_processing_failed", "Failed to process the avatar image.");
+ public static readonly Error NotFound = new("avatar_not_found", "Avatar not found.");
+ }
+ // @end
+
+ // @feature file-storage
+ ///
+ /// File storage (S3-compatible) error messages.
+ ///
+ public static class FileStorage
+ {
+ public static readonly Error UploadFailed = new("file_storage_upload_failed", "Failed to upload file to storage.");
+ public static readonly Error DownloadFailed = new("file_storage_download_failed", "Failed to retrieve file from storage.");
+ public static readonly Error DeleteFailed = new("file_storage_delete_failed", "Failed to delete file from storage.");
+ public static readonly Error NotFound = new("file_storage_not_found", "File not found.");
}
// @end
@@ -179,23 +217,23 @@ public static class Avatar
///
public static class ExternalAuth
{
- public const string ProviderNotConfigured = "The requested authentication provider is not configured.";
- public const string InvalidState = "Invalid or missing OAuth state token.";
- public const string StateExpired = "OAuth state token has expired. Please try again.";
- public const string EmailNotVerified = "Your email address must be verified before linking an external account. Please verify your email first.";
- public const string AlreadyLinkedToOtherUser = "This external account is already linked to another user.";
- public const string ProviderNotLinked = "This provider is not linked to your account.";
- public const string CannotUnlinkLastMethod = "Cannot unlink this provider because it is your only sign-in method. Set a password first.";
- public const string CodeExchangeFailed = "Failed to exchange the authorization code with the provider.";
- public const string ProviderError = "The external authentication provider returned an error.";
- public const string InvalidRedirectUri = "The provided redirect URI is not allowed.";
- public const string PasswordAlreadySet = "A password is already set for this account.";
- public const string PasswordSetFailed = "Failed to set the password. Please try again.";
- public const string UnknownProvider = "The specified authentication provider is not recognized.";
- public const string ClientSecretRequired = "A client secret is required when enabling a provider that has no existing secret.";
- public const string TestConnectionInvalidCredentials = "The provider rejected the credentials. Verify the client ID and secret are correct.";
- public const string TestConnectionProviderUnreachable = "Could not reach the authentication provider. Please try again later.";
- public const string TestConnectionNotConfigured = "No credentials are configured for this provider.";
+ public static readonly Error ProviderNotConfigured = new("external_auth_provider_not_configured", "The requested authentication provider is not configured.");
+ public static readonly Error InvalidState = new("external_auth_invalid_state", "Invalid or missing OAuth state token.");
+ public static readonly Error StateExpired = new("external_auth_state_expired", "OAuth state token has expired. Please try again.");
+ public static readonly Error EmailNotVerified = new("external_auth_email_not_verified", "Your email address must be verified before linking an external account. Please verify your email first.");
+ public static readonly Error AlreadyLinkedToOtherUser = new("external_auth_already_linked_to_other_user", "This external account is already linked to another user.");
+ public static readonly Error ProviderNotLinked = new("external_auth_provider_not_linked", "This provider is not linked to your account.");
+ public static readonly Error CannotUnlinkLastMethod = new("external_auth_cannot_unlink_last_method", "Cannot unlink this provider because it is your only sign-in method. Set a password first.");
+ public static readonly Error CodeExchangeFailed = new("external_auth_code_exchange_failed", "Failed to exchange the authorization code with the provider.");
+ public static readonly Error ProviderError = new("external_auth_provider_error", "The external authentication provider returned an error.");
+ public static readonly Error InvalidRedirectUri = new("external_auth_invalid_redirect_uri", "The provided redirect URI is not allowed.");
+ public static readonly Error PasswordAlreadySet = new("external_auth_password_already_set", "A password is already set for this account.");
+ public static readonly Error PasswordSetFailed = new("external_auth_password_set_failed", "Failed to set the password. Please try again.");
+ public static readonly Error UnknownProvider = new("external_auth_unknown_provider", "The specified authentication provider is not recognized.");
+ public static readonly Error ClientSecretRequired = new("external_auth_client_secret_required", "A client secret is required when enabling a provider that has no existing secret.");
+ public static readonly Error TestConnectionInvalidCredentials = new("external_auth_test_connection_invalid_credentials", "The provider rejected the credentials. Verify the client ID and secret are correct.");
+ public static readonly Error TestConnectionProviderUnreachable = new("external_auth_test_connection_provider_unreachable", "Could not reach the authentication provider. Please try again later.");
+ public static readonly Error TestConnectionNotConfigured = new("external_auth_test_connection_not_configured", "No credentials are configured for this provider.");
}
// @end
@@ -204,8 +242,8 @@ public static class ExternalAuth
///
public static class Entity
{
- public const string AddFailed = "Failed to add entity.";
- public const string NotFound = "Entity not found.";
- public const string NotDeleted = "Entity could not be deleted.";
+ public static readonly Error AddFailed = new("entity_add_failed", "Failed to add entity.");
+ public static readonly Error NotFound = new("entity_not_found", "Entity not found.");
+ public static readonly Error NotDeleted = new("entity_not_deleted", "Entity could not be deleted.");
}
}
diff --git a/templates/src/backend/MyProject.Shared/Result.cs b/templates/src/backend/MyProject.Shared/Result.cs
index 615eb68..86b3fbc 100644
--- a/templates/src/backend/MyProject.Shared/Result.cs
+++ b/templates/src/backend/MyProject.Shared/Result.cs
@@ -17,9 +17,9 @@ public class Result
public bool IsFailure => !IsSuccess;
///
- /// Gets the error message if the operation failed.
+ /// Gets the error (machine-readable code plus human-readable message) if the operation failed.
///
- public string? Error { get; }
+ public Error? Error { get; }
///
/// Gets the error category for the failure, or null when not specified (defaults to 400).
@@ -30,7 +30,7 @@ public class Result
///
/// Initializes a new instance of the class.
///
- protected Result(bool isSuccess, string? error = null, ErrorType? errorType = null)
+ protected Result(bool isSuccess, Error? error = null, ErrorType? errorType = null)
{
IsSuccess = isSuccess;
Error = error;
@@ -47,23 +47,23 @@ public static Result Success()
}
///
- /// Creates a failed result with the specified error message.
+ /// Creates a failed result with the specified error.
/// Defaults to 400 (Validation) at the controller layer.
///
- /// The error message.
- /// A failed result containing the error message.
- public static Result Failure(string error)
+ /// The error, typically an entry.
+ /// A failed result containing the error.
+ public static Result Failure(Error error)
{
return new Result(false, error, Shared.ErrorType.Validation);
}
///
- /// Creates a failed result with the specified error message and error category.
+ /// Creates a failed result with the specified error and error category.
///
- /// The error message.
+ /// The error, typically an entry.
/// The error category for HTTP status code mapping.
- /// A failed result containing the error message and error type.
- public static Result Failure(string error, ErrorType errorType)
+ /// A failed result containing the error and error type.
+ public static Result Failure(Error error, ErrorType errorType)
{
return new Result(false, error, errorType);
}
@@ -88,7 +88,7 @@ public class Result : Result
? _value!
: throw new InvalidOperationException("Cannot access Value on a failed result.");
- private Result(bool isSuccess, string? error, T? value, ErrorType? errorType = null)
+ private Result(bool isSuccess, Error? error, T? value, ErrorType? errorType = null)
: base(isSuccess, error, errorType)
{
_value = value;
@@ -105,23 +105,23 @@ public static Result Success(T value)
}
///
- /// Creates a failed result with the specified error message.
+ /// Creates a failed result with the specified error.
/// Defaults to 400 (Validation) at the controller layer.
///
- /// The error message.
- /// A failed result containing the error message.
- public new static Result Failure(string error)
+ /// The error, typically an entry.
+ /// A failed result containing the error.
+ public new static Result Failure(Error error)
{
return new Result(false, error, default, Shared.ErrorType.Validation);
}
///
- /// Creates a failed result with the specified error message and error category.
+ /// Creates a failed result with the specified error and error category.
///
- /// The error message.
+ /// The error, typically an entry.
/// The error category for HTTP status code mapping.
- /// A failed result containing the error message and error type.
- public new static Result Failure(string error, ErrorType errorType)
+ /// A failed result containing the error and error type.
+ public new static Result Failure(Error error, ErrorType errorType)
{
return new Result(false, error, default, errorType);
}
diff --git a/templates/src/backend/MyProject.WebApi/Authorization/ProblemDetailsAuthorizationHandler.cs b/templates/src/backend/MyProject.WebApi/Authorization/ProblemDetailsAuthorizationHandler.cs
index 12ed2d5..7aee666 100644
--- a/templates/src/backend/MyProject.WebApi/Authorization/ProblemDetailsAuthorizationHandler.cs
+++ b/templates/src/backend/MyProject.WebApi/Authorization/ProblemDetailsAuthorizationHandler.cs
@@ -2,6 +2,7 @@
using Microsoft.AspNetCore.Authorization.Policy;
using Microsoft.AspNetCore.Mvc;
using MyProject.Shared;
+using MyProject.WebApi.Shared;
namespace MyProject.WebApi.Authorization;
@@ -28,11 +29,8 @@ public async Task HandleAsync(
await problemDetailsService.WriteAsync(new ProblemDetailsContext
{
HttpContext = httpContext,
- ProblemDetails = new ProblemDetails
- {
- Status = StatusCodes.Status401Unauthorized,
- Detail = ErrorMessages.Auth.NotAuthenticated
- }
+ ProblemDetails = ProblemFactory.CreateProblemDetails(
+ ErrorMessages.Auth.NotAuthenticated, StatusCodes.Status401Unauthorized)
});
return;
}
@@ -44,11 +42,8 @@ await problemDetailsService.WriteAsync(new ProblemDetailsContext
await problemDetailsService.WriteAsync(new ProblemDetailsContext
{
HttpContext = httpContext,
- ProblemDetails = new ProblemDetails
- {
- Status = StatusCodes.Status403Forbidden,
- Detail = ErrorMessages.Auth.InsufficientPermissions
- }
+ ProblemDetails = ProblemFactory.CreateProblemDetails(
+ ErrorMessages.Auth.InsufficientPermissions, StatusCodes.Status403Forbidden)
});
return;
}
diff --git a/templates/src/backend/MyProject.WebApi/Extensions/RateLimiterExtensions.cs b/templates/src/backend/MyProject.WebApi/Extensions/RateLimiterExtensions.cs
index 3568cf6..7acc232 100644
--- a/templates/src/backend/MyProject.WebApi/Extensions/RateLimiterExtensions.cs
+++ b/templates/src/backend/MyProject.WebApi/Extensions/RateLimiterExtensions.cs
@@ -2,6 +2,7 @@
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
+using MyProject.Shared;
using MyProject.WebApi.Options;
using MyProject.WebApi.Shared;
@@ -92,15 +93,15 @@ private static void ConfigureOnRejected(RateLimiterOptions options)
context.HttpContext.Response.Headers.RetryAfter = retryAfterSeconds.ToString(CultureInfo.InvariantCulture);
var problemDetailsService = context.HttpContext.RequestServices.GetRequiredService();
+ var error = ErrorMessages.Server.TooManyRequests with
+ {
+ Message = $"Too many requests. Please try again in {retryAfterSeconds} seconds."
+ };
+
await problemDetailsService.WriteAsync(new ProblemDetailsContext
{
HttpContext = context.HttpContext,
- ProblemDetails = new ProblemDetails
- {
- Status = StatusCodes.Status429TooManyRequests,
- Title = "Too Many Requests",
- Detail = $"Too many requests. Please try again in {retryAfterSeconds} seconds."
- }
+ ProblemDetails = ProblemFactory.CreateProblemDetails(error, StatusCodes.Status429TooManyRequests)
});
};
}
diff --git a/templates/src/backend/MyProject.WebApi/Features/OpenApi/Extensions/WebApplicationBuilderExtensions.cs b/templates/src/backend/MyProject.WebApi/Features/OpenApi/Extensions/WebApplicationBuilderExtensions.cs
index 78d596d..1ddc9aa 100644
--- a/templates/src/backend/MyProject.WebApi/Features/OpenApi/Extensions/WebApplicationBuilderExtensions.cs
+++ b/templates/src/backend/MyProject.WebApi/Features/OpenApi/Extensions/WebApplicationBuilderExtensions.cs
@@ -23,6 +23,7 @@ public static WebApplicationBuilder AddOpenApiSpecification(this WebApplicationB
opt.AddOperationTransformer();
opt.AddSchemaTransformer();
opt.AddSchemaTransformer();
+ opt.AddSchemaTransformer();
});
return builder;
diff --git a/templates/src/backend/MyProject.WebApi/Features/OpenApi/Transformers/ProblemDetailsSchemaTransformer.cs b/templates/src/backend/MyProject.WebApi/Features/OpenApi/Transformers/ProblemDetailsSchemaTransformer.cs
new file mode 100644
index 0000000..be22552
--- /dev/null
+++ b/templates/src/backend/MyProject.WebApi/Features/OpenApi/Transformers/ProblemDetailsSchemaTransformer.cs
@@ -0,0 +1,37 @@
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.OpenApi;
+using Microsoft.OpenApi;
+using MyProject.WebApi.Shared;
+
+namespace MyProject.WebApi.Features.OpenApi.Transformers;
+
+///
+/// Documents the code extension on every -derived schema.
+/// The extension is written at runtime via ProblemDetails.Extensions, which the schema
+/// generator cannot see, so it is declared here to keep generated clients (frontend v1.d.ts) accurate.
+///
+/// See .claude/skills/backend-conventions/SKILL.md for the error code convention.
+internal sealed class ProblemDetailsSchemaTransformer : IOpenApiSchemaTransformer
+{
+ ///
+ public Task TransformAsync(
+ OpenApiSchema schema,
+ OpenApiSchemaTransformerContext context,
+ CancellationToken cancellationToken)
+ {
+ if (!typeof(ProblemDetails).IsAssignableFrom(context.JsonTypeInfo.Type))
+ {
+ return Task.CompletedTask;
+ }
+
+ schema.Properties ??= new Dictionary();
+ schema.Properties[ProblemFactory.CodeExtensionKey] = new OpenApiSchema
+ {
+ Type = JsonSchemaType.String,
+ Description = "Stable, machine-readable error code (snake_case). Use it to branch on the error " +
+ "or as a translation key instead of matching the human-readable detail."
+ };
+
+ return Task.CompletedTask;
+ }
+}
diff --git a/templates/src/backend/MyProject.WebApi/Middlewares/ExceptionHandlingMiddleware.cs b/templates/src/backend/MyProject.WebApi/Middlewares/ExceptionHandlingMiddleware.cs
index 533fe01..945eee3 100644
--- a/templates/src/backend/MyProject.WebApi/Middlewares/ExceptionHandlingMiddleware.cs
+++ b/templates/src/backend/MyProject.WebApi/Middlewares/ExceptionHandlingMiddleware.cs
@@ -2,6 +2,7 @@
using Microsoft.AspNetCore.Mvc;
using MyProject.Infrastructure.Persistence.Exceptions;
using MyProject.Shared;
+using MyProject.WebApi.Shared;
namespace MyProject.WebApi.Middlewares;
@@ -31,13 +32,13 @@ public async Task Invoke(HttpContext context)
{
logger.LogWarning(keyNotFoundEx, "A KeyNotFoundException occurred.");
await HandleExceptionAsync(context, keyNotFoundEx, HttpStatusCode.NotFound,
- customMessage: ErrorMessages.Entity.NotFound);
+ ErrorMessages.Entity.NotFound);
}
catch (PaginationException paginationEx)
{
logger.LogWarning(paginationEx, "A PaginationException occurred.");
await HandleExceptionAsync(context, paginationEx, HttpStatusCode.BadRequest,
- customMessage: paginationEx.ParamName is "pageSize"
+ paginationEx.ParamName is "pageSize"
? ErrorMessages.Pagination.InvalidPageSize
: ErrorMessages.Pagination.InvalidPage);
}
@@ -45,7 +46,7 @@ await HandleExceptionAsync(context, paginationEx, HttpStatusCode.BadRequest,
{
logger.LogError(e, "An unhandled exception occurred.");
await HandleExceptionAsync(context, e, HttpStatusCode.InternalServerError,
- customMessage: ErrorMessages.Server.InternalError);
+ ErrorMessages.Server.InternalError);
}
}
@@ -53,16 +54,12 @@ private async Task HandleExceptionAsync(
HttpContext context,
Exception exception,
HttpStatusCode statusCode,
- string? customMessage = null)
+ Error error)
{
var status = (int)statusCode;
context.Response.StatusCode = status;
- var problemDetails = new ProblemDetails
- {
- Status = status,
- Detail = customMessage ?? ErrorMessages.Server.InternalError
- };
+ var problemDetails = ProblemFactory.CreateProblemDetails(error, status);
if (env.IsDevelopment() && exception.StackTrace is not null)
{
diff --git a/templates/src/backend/MyProject.WebApi/Middlewares/OriginValidationMiddleware.cs b/templates/src/backend/MyProject.WebApi/Middlewares/OriginValidationMiddleware.cs
index 6c59509..f9a8fb9 100644
--- a/templates/src/backend/MyProject.WebApi/Middlewares/OriginValidationMiddleware.cs
+++ b/templates/src/backend/MyProject.WebApi/Middlewares/OriginValidationMiddleware.cs
@@ -1,6 +1,6 @@
-using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using MyProject.Shared;
+using MyProject.WebApi.Shared;
using CorsOptions = MyProject.WebApi.Options.CorsOptions;
namespace MyProject.WebApi.Middlewares;
@@ -74,11 +74,8 @@ public async Task Invoke(HttpContext context)
await problemDetailsService.WriteAsync(new ProblemDetailsContext
{
HttpContext = context,
- ProblemDetails = new ProblemDetails
- {
- Status = StatusCodes.Status403Forbidden,
- Detail = ErrorMessages.Security.CrossOriginRequestBlocked
- }
+ ProblemDetails = ProblemFactory.CreateProblemDetails(
+ ErrorMessages.Security.CrossOriginRequestBlocked, StatusCodes.Status403Forbidden)
});
}
}
diff --git a/templates/src/backend/MyProject.WebApi/Program.cs b/templates/src/backend/MyProject.WebApi/Program.cs
index 7de07ec..a28abec 100644
--- a/templates/src/backend/MyProject.WebApi/Program.cs
+++ b/templates/src/backend/MyProject.WebApi/Program.cs
@@ -50,6 +50,7 @@
// @end
// @end
// @end
+using MyProject.WebApi.Shared;
using Serilog;
using LoggerConfigurationExtensions = MyProject.Infrastructure.Logging.Extensions.LoggerConfigurationExtensions;
@@ -165,6 +166,7 @@
options.CustomizeProblemDetails = context =>
{
context.ProblemDetails.Instance = context.HttpContext.Request.Path;
+ ProblemFactory.EnsureCode(context.ProblemDetails);
};
});
diff --git a/templates/src/backend/MyProject.WebApi/Shared/ProblemFactory.cs b/templates/src/backend/MyProject.WebApi/Shared/ProblemFactory.cs
index 8545e90..47dfbc2 100644
--- a/templates/src/backend/MyProject.WebApi/Shared/ProblemFactory.cs
+++ b/templates/src/backend/MyProject.WebApi/Shared/ProblemFactory.cs
@@ -5,30 +5,90 @@
namespace MyProject.WebApi.Shared;
///
-/// Creates -based action results from atomic error values.
-/// Produces a consistent body with Title set from the
-/// status code's reason phrase.
+/// Creates bodies and action results from values.
+/// Every body carries the human-readable message in detail and the stable, machine-readable
+/// error code in the code extension so clients never have to match on English text.
///
internal static class ProblemFactory
{
///
- /// Returns a response with the specified detail and error type.
+ /// Name of the entry that carries the machine-readable error code.
///
- /// The error detail message.
+ public const string CodeExtensionKey = "code";
+
+ ///
+ /// Code applied to framework-generated validation failures (),
+ /// whose field-level details live in errors.
+ ///
+ public const string ValidationFailedCode = "validation_failed";
+
+ ///
+ /// Returns a action result for the specified error and error type.
+ ///
+ /// The error to report. Null is tolerated for defensive call sites and yields no detail or code.
/// The error category. Defaults to 400 Bad Request when null.
- public static ObjectResult Create(string? detail, ErrorType? errorType = null)
+ public static ObjectResult Create(Error? error, ErrorType? errorType = null)
{
- var code = ToStatusCode(errorType);
+ var statusCode = ToStatusCode(errorType);
var problemDetails = new ProblemDetails
{
- Status = code,
- Detail = detail,
- Title = ReasonPhrases.GetReasonPhrase(code),
- Type = $"https://tools.ietf.org/html/rfc9110#section-15.5.{code - 399}"
+ Status = statusCode,
+ Title = ReasonPhrases.GetReasonPhrase(statusCode),
+ Type = $"https://tools.ietf.org/html/rfc9110#section-15.5.{statusCode - 399}"
+ };
+
+ if (error is not null)
+ {
+ problemDetails.Detail = error.Message;
+ problemDetails.Extensions[CodeExtensionKey] = error.Code;
+ }
+
+ return new ObjectResult(problemDetails) { StatusCode = statusCode };
+ }
+
+ ///
+ /// Creates a bare body (status, detail, code) for middleware and handlers
+ /// that write through , which fills in title and type defaults.
+ ///
+ /// The error to report.
+ /// The HTTP status code of the response.
+ public static ProblemDetails CreateProblemDetails(Error error, int statusCode)
+ {
+ return new ProblemDetails
+ {
+ Status = statusCode,
+ Detail = error.Message,
+ Extensions = { [CodeExtensionKey] = error.Code }
};
+ }
+
+ ///
+ /// Ensures a code extension is present on framework-generated problem details
+ /// (model validation, status code pages, unmatched routes). Bodies that already carry a code are left untouched.
+ /// Validation failures get ; everything else falls back to the
+ /// snake_case reason phrase of the status (for example not_found, method_not_allowed).
+ ///
+ /// The problem details being written.
+ public static void EnsureCode(ProblemDetails problemDetails)
+ {
+ if (problemDetails.Extensions.ContainsKey(CodeExtensionKey))
+ {
+ return;
+ }
+
+ problemDetails.Extensions[CodeExtensionKey] = problemDetails is HttpValidationProblemDetails
+ ? ValidationFailedCode
+ : ToFallbackCode(problemDetails.Status ?? StatusCodes.Status500InternalServerError);
+ }
+
+ private static string ToFallbackCode(int statusCode)
+ {
+ var reasonPhrase = ReasonPhrases.GetReasonPhrase(statusCode);
- return new ObjectResult(problemDetails) { StatusCode = code };
+ return string.IsNullOrEmpty(reasonPhrase)
+ ? $"http_{statusCode}"
+ : reasonPhrase.Replace(' ', '_').Replace('-', '_').ToLowerInvariant();
}
private static int ToStatusCode(ErrorType? errorType) => errorType switch
diff --git a/templates/src/backend/tests/MyProject.Api.Tests/Controllers/AdminControllerDisableTwoFactorTests.cs b/templates/src/backend/tests/MyProject.Api.Tests/Controllers/AdminControllerDisableTwoFactorTests.cs
index 5b5a916..ae1396a 100644
--- a/templates/src/backend/tests/MyProject.Api.Tests/Controllers/AdminControllerDisableTwoFactorTests.cs
+++ b/templates/src/backend/tests/MyProject.Api.Tests/Controllers/AdminControllerDisableTwoFactorTests.cs
@@ -1,7 +1,6 @@
// @feature 2fa
using System.Net;
using System.Net.Http.Json;
-using System.Text.Json;
using MyProject.Api.Tests.Fixtures;
using MyProject.Application.Identity.Constants;
using MyProject.Shared;
@@ -29,16 +28,6 @@ private static HttpRequestMessage Post(string url, string auth, HttpContent? con
return request;
}
- private static async Task AssertProblemDetailsAsync(
- HttpResponseMessage response, int expectedStatus, string? expectedDetail = null)
- {
- var json = await response.Content.ReadFromJsonAsync();
- Assert.Equal(expectedStatus, json.GetProperty("status").GetInt32());
- if (expectedDetail is not null)
- {
- Assert.Equal(expectedDetail, json.GetProperty("detail").GetString());
- }
- }
[Fact]
public async Task DisableTwoFactor_WithPermission_Returns204()
@@ -93,7 +82,7 @@ public async Task DisableTwoFactor_NotFound_Returns404()
JsonContent.Create(new { Reason = (string?)null })));
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
- await AssertProblemDetailsAsync(response, 404, ErrorMessages.Admin.UserNotFound);
+ await ProblemDetailsAssert.MatchesAsync(response, 404, ErrorMessages.Admin.UserNotFound);
}
[Fact]
@@ -110,7 +99,7 @@ public async Task DisableTwoFactor_TwoFactorNotEnabled_Returns400()
JsonContent.Create(new { Reason = (string?)null })));
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
- await AssertProblemDetailsAsync(response, 400, ErrorMessages.Admin.TwoFactorNotEnabled);
+ await ProblemDetailsAssert.MatchesAsync(response, 400, ErrorMessages.Admin.TwoFactorNotEnabled);
}
[Fact]
diff --git a/templates/src/backend/tests/MyProject.Api.Tests/Controllers/AdminControllerTests.cs b/templates/src/backend/tests/MyProject.Api.Tests/Controllers/AdminControllerTests.cs
index da46c66..eb8e200 100644
--- a/templates/src/backend/tests/MyProject.Api.Tests/Controllers/AdminControllerTests.cs
+++ b/templates/src/backend/tests/MyProject.Api.Tests/Controllers/AdminControllerTests.cs
@@ -52,16 +52,6 @@ private static HttpRequestMessage Delete(string url, string auth)
return request;
}
- private static async Task AssertProblemDetailsAsync(
- HttpResponseMessage response, int expectedStatus, string? expectedDetail = null)
- {
- var json = await response.Content.ReadFromJsonAsync();
- Assert.Equal(expectedStatus, json.GetProperty("status").GetInt32());
- if (expectedDetail is not null)
- {
- Assert.Equal(expectedDetail, json.GetProperty("detail").GetString());
- }
- }
#region ListUsers
@@ -155,7 +145,7 @@ public async Task GetUser_NotFound_Returns404WithProblemDetails()
Get($"/api/v1/admin/users/{userId}", TestAuth.WithPermissions(AppPermissions.Users.View)));
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
- await AssertProblemDetailsAsync(response, 404, ErrorMessages.Admin.UserNotFound);
+ await ProblemDetailsAssert.MatchesAsync(response, 404, ErrorMessages.Admin.UserNotFound);
}
[Fact]
@@ -331,7 +321,7 @@ public async Task AssignRole_ServiceFailure_Returns400WithProblemDetails()
JsonContent.Create(new { Role = "Admin" })));
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
- await AssertProblemDetailsAsync(response, 400, ErrorMessages.Admin.RoleAssignAboveRank);
+ await ProblemDetailsAssert.MatchesAsync(response, 400, ErrorMessages.Admin.RoleAssignAboveRank);
}
[Fact]
@@ -348,7 +338,7 @@ public async Task AssignRole_CustomRoleEscalation_Returns403()
JsonContent.Create(new { Role = "PrivilegedRole" })));
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
- await AssertProblemDetailsAsync(response, 403, ErrorMessages.Admin.RoleAssignEscalation);
+ await ProblemDetailsAssert.MatchesAsync(response, 403, ErrorMessages.Admin.RoleAssignEscalation);
}
[Fact]
@@ -365,7 +355,7 @@ public async Task AssignRole_EmailNotVerified_Returns400()
JsonContent.Create(new { Role = "User" })));
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
- await AssertProblemDetailsAsync(response, 400, ErrorMessages.Admin.EmailVerificationRequired);
+ await ProblemDetailsAssert.MatchesAsync(response, 400, ErrorMessages.Admin.EmailVerificationRequired);
}
#endregion
@@ -467,7 +457,7 @@ public async Task DeleteUser_NotFound_Returns404WithProblemDetails()
Delete($"/api/v1/admin/users/{userId}", TestAuth.WithPermissions(AppPermissions.Users.Manage)));
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
- await AssertProblemDetailsAsync(response, 404, ErrorMessages.Admin.UserNotFound);
+ await ProblemDetailsAssert.MatchesAsync(response, 404, ErrorMessages.Admin.UserNotFound);
}
[Fact]
@@ -516,7 +506,7 @@ public async Task VerifyEmail_NotFound_Returns404()
Post($"/api/v1/admin/users/{userId}/verify-email", TestAuth.WithPermissions(AppPermissions.Users.Manage)));
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
- await AssertProblemDetailsAsync(response, 404, ErrorMessages.Admin.UserNotFound);
+ await ProblemDetailsAssert.MatchesAsync(response, 404, ErrorMessages.Admin.UserNotFound);
}
[Fact]
@@ -530,7 +520,7 @@ public async Task VerifyEmail_AlreadyVerified_Returns400()
Post($"/api/v1/admin/users/{userId}/verify-email", TestAuth.WithPermissions(AppPermissions.Users.Manage)));
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
- await AssertProblemDetailsAsync(response, 400, ErrorMessages.Auth.EmailAlreadyVerified);
+ await ProblemDetailsAssert.MatchesAsync(response, 400, ErrorMessages.Auth.EmailAlreadyVerified);
}
#endregion
@@ -570,7 +560,7 @@ public async Task SendPasswordReset_NotFound_Returns404()
Post($"/api/v1/admin/users/{userId}/send-password-reset", TestAuth.WithPermissions(AppPermissions.Users.Manage)));
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
- await AssertProblemDetailsAsync(response, 404, ErrorMessages.Admin.UserNotFound);
+ await ProblemDetailsAssert.MatchesAsync(response, 404, ErrorMessages.Admin.UserNotFound);
}
#endregion
@@ -616,7 +606,7 @@ public async Task CreateUser_DuplicateEmail_Returns400()
JsonContent.Create(new { Email = "existing@test.com" })));
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
- await AssertProblemDetailsAsync(response, 400, ErrorMessages.Admin.EmailAlreadyRegistered);
+ await ProblemDetailsAssert.MatchesAsync(response, 400, ErrorMessages.Admin.EmailAlreadyRegistered);
}
#endregion
@@ -682,7 +672,7 @@ public async Task GetRole_NotFound_Returns404WithProblemDetails()
Get($"/api/v1/admin/roles/{roleId}", TestAuth.WithPermissions(AppPermissions.Roles.View)));
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
- await AssertProblemDetailsAsync(response, 404, ErrorMessages.Roles.RoleNotFound);
+ await ProblemDetailsAssert.MatchesAsync(response, 404, ErrorMessages.Roles.RoleNotFound);
}
[Fact]
@@ -806,7 +796,7 @@ public async Task SetPermissions_CallerLacksPermission_Returns403()
JsonContent.Create(new { Permissions = new[] { "users.view" } })));
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
- await AssertProblemDetailsAsync(response, 403, ErrorMessages.Roles.CannotGrantUnheldPermission);
+ await ProblemDetailsAssert.MatchesAsync(response, 403, ErrorMessages.Roles.CannotGrantUnheldPermission);
}
[Fact]
@@ -823,7 +813,7 @@ public async Task SetPermissions_RoleNotFound_Returns404()
JsonContent.Create(new { Permissions = new[] { "users.view" } })));
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
- await AssertProblemDetailsAsync(response, 404, ErrorMessages.Roles.RoleNotFound);
+ await ProblemDetailsAssert.MatchesAsync(response, 404, ErrorMessages.Roles.RoleNotFound);
}
[Fact]
diff --git a/templates/src/backend/tests/MyProject.Api.Tests/Controllers/AuthControllerTests.cs b/templates/src/backend/tests/MyProject.Api.Tests/Controllers/AuthControllerTests.cs
index 22bc51a..af9210b 100644
--- a/templates/src/backend/tests/MyProject.Api.Tests/Controllers/AuthControllerTests.cs
+++ b/templates/src/backend/tests/MyProject.Api.Tests/Controllers/AuthControllerTests.cs
@@ -1,6 +1,5 @@
using System.Net;
using System.Net.Http.Json;
-using System.Text.Json;
using MyProject.Api.Tests.Contracts;
using MyProject.Api.Tests.Fixtures;
using MyProject.Application.Cookies.Constants;
@@ -30,16 +29,6 @@ private static HttpRequestMessage Post(string url, HttpContent? content = null,
return msg;
}
- private static async Task AssertProblemDetailsAsync(
- HttpResponseMessage response, int expectedStatus, string? expectedDetail = null)
- {
- var json = await response.Content.ReadFromJsonAsync();
- Assert.Equal(expectedStatus, json.GetProperty("status").GetInt32());
- if (expectedDetail is not null)
- {
- Assert.Equal(expectedDetail, json.GetProperty("detail").GetString());
- }
- }
#region Login
@@ -75,7 +64,7 @@ public async Task Login_InvalidCredentials_Returns401WithProblemDetails()
Post("/api/auth/login", JsonContent.Create(new { Username = "test@example.com", Password = "wrong" })));
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
- await AssertProblemDetailsAsync(response, 401, ErrorMessages.Auth.LoginInvalidCredentials);
+ await ProblemDetailsAssert.MatchesAsync(response, 401, ErrorMessages.Auth.LoginInvalidCredentials);
}
[Fact]
@@ -136,13 +125,13 @@ public async Task Register_ServiceFailure_Returns400WithProblemDetails()
.Returns(true);
// @end
_factory.AuthenticationService.Register(Arg.Any(), Arg.Any())
- .Returns(Result.Failure("Email already registered."));
+ .Returns(Result.Failure(ErrorMessages.Auth.RegistrationInvalid));
var response = await _client.SendAsync(
Post("/api/auth/register", JsonContent.Create(new { Email = "dup@example.com", Password = "Password1!", CaptchaToken = "valid-token" })));
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
- await AssertProblemDetailsAsync(response, 400, "Email already registered.");
+ await ProblemDetailsAssert.MatchesAsync(response, 400, ErrorMessages.Auth.RegistrationInvalid);
}
// @feature captcha
@@ -156,7 +145,7 @@ public async Task Register_InvalidCaptcha_Returns400()
Post("/api/auth/register", JsonContent.Create(new { Email = "new@example.com", Password = "Password1!", CaptchaToken = "invalid-token" })));
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
- await AssertProblemDetailsAsync(response, 400, ErrorMessages.Auth.CaptchaInvalid);
+ await ProblemDetailsAssert.MatchesAsync(response, 400, ErrorMessages.Auth.CaptchaInvalid);
}
[Fact]
@@ -214,7 +203,7 @@ public async Task Refresh_MissingToken_Returns401WithProblemDetails()
Post("/api/auth/refresh", JsonContent.Create(new { })));
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
- await AssertProblemDetailsAsync(response, 401, ErrorMessages.Auth.TokenMissing);
+ await ProblemDetailsAssert.MatchesAsync(response, 401, ErrorMessages.Auth.TokenMissing);
}
[Fact]
@@ -246,7 +235,7 @@ public async Task Refresh_InvalidToken_Returns401WithProblemDetails()
Post("/api/auth/refresh", JsonContent.Create(new { RefreshToken = "invalid" })));
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
- await AssertProblemDetailsAsync(response, 401, ErrorMessages.Auth.TokenInvalidated);
+ await ProblemDetailsAssert.MatchesAsync(response, 401, ErrorMessages.Auth.TokenInvalidated);
}
[Fact]
@@ -285,7 +274,7 @@ public async Task Logout_Unauthenticated_Returns401()
var response = await _client.SendAsync(Post("/api/auth/logout"));
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
- await AssertProblemDetailsAsync(response, 401, ErrorMessages.Auth.NotAuthenticated);
+ await ProblemDetailsAssert.MatchesAsync(response, 401, ErrorMessages.Auth.NotAuthenticated);
}
#endregion
@@ -322,7 +311,7 @@ public async Task ChangePassword_ServiceFailure_Returns400WithProblemDetails()
{
_factory.AuthenticationService.ChangePasswordAsync(
Arg.Any(), Arg.Any())
- .Returns(Result.Failure("Current password is incorrect."));
+ .Returns(Result.Failure(ErrorMessages.Auth.PasswordIncorrect));
var response = await _client.SendAsync(
Post("/api/auth/password/change",
@@ -330,7 +319,7 @@ public async Task ChangePassword_ServiceFailure_Returns400WithProblemDetails()
TestAuth.User()));
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
- await AssertProblemDetailsAsync(response, 400, "Current password is incorrect.");
+ await ProblemDetailsAssert.MatchesAsync(response, 400, ErrorMessages.Auth.PasswordIncorrect);
}
#endregion
@@ -383,7 +372,7 @@ public async Task ForgotPassword_InvalidCaptcha_Returns400()
Post("/api/auth/password/forgot", JsonContent.Create(new { Email = "test@example.com", CaptchaToken = "invalid-token" })));
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
- await AssertProblemDetailsAsync(response, 400, ErrorMessages.Auth.CaptchaInvalid);
+ await ProblemDetailsAssert.MatchesAsync(response, 400, ErrorMessages.Auth.CaptchaInvalid);
}
[Fact]
@@ -432,7 +421,7 @@ public async Task ResetPassword_InvalidToken_Returns400WithProblemDetails()
})));
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
- await AssertProblemDetailsAsync(response, 400, ErrorMessages.Auth.ResetPasswordTokenInvalid);
+ await ProblemDetailsAssert.MatchesAsync(response, 400, ErrorMessages.Auth.ResetPasswordTokenInvalid);
}
[Fact]
@@ -494,7 +483,7 @@ public async Task VerifyEmail_InvalidToken_Returns400WithProblemDetails()
})));
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
- await AssertProblemDetailsAsync(response, 400, ErrorMessages.Auth.EmailVerificationFailed);
+ await ProblemDetailsAssert.MatchesAsync(response, 400, ErrorMessages.Auth.EmailVerificationFailed);
}
[Fact]
@@ -541,7 +530,7 @@ public async Task ResendVerification_AlreadyVerified_Returns400()
Post("/api/auth/email/resend-verification", auth: TestAuth.User()));
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
- await AssertProblemDetailsAsync(response, 400, ErrorMessages.Auth.EmailAlreadyVerified);
+ await ProblemDetailsAssert.MatchesAsync(response, 400, ErrorMessages.Auth.EmailAlreadyVerified);
}
#endregion
@@ -604,7 +593,7 @@ public async Task TwoFactorLogin_InvalidCode_Returns401()
Post("/api/auth/two-factor/login", JsonContent.Create(new { ChallengeToken = "challenge-token", Code = "000000" })));
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
- await AssertProblemDetailsAsync(response, 401, ErrorMessages.TwoFactor.InvalidCode);
+ await ProblemDetailsAssert.MatchesAsync(response, 401, ErrorMessages.TwoFactor.InvalidCode);
}
[Fact]
@@ -658,7 +647,7 @@ public async Task TwoFactorRecoveryLogin_InvalidCode_Returns401()
Post("/api/auth/two-factor/login/recovery", JsonContent.Create(new { ChallengeToken = "challenge-token", RecoveryCode = "BAD-CODE" })));
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
- await AssertProblemDetailsAsync(response, 401, ErrorMessages.TwoFactor.RecoveryCodeInvalid);
+ await ProblemDetailsAssert.MatchesAsync(response, 401, ErrorMessages.TwoFactor.RecoveryCodeInvalid);
}
[Fact]
@@ -698,7 +687,7 @@ public async Task TwoFactorSetup_Unauthenticated_Returns401()
Post("/api/auth/two-factor/setup"));
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
- await AssertProblemDetailsAsync(response, 401, ErrorMessages.Auth.NotAuthenticated);
+ await ProblemDetailsAssert.MatchesAsync(response, 401, ErrorMessages.Auth.NotAuthenticated);
}
#endregion
@@ -736,7 +725,7 @@ public async Task TwoFactorVerifySetup_InvalidCode_Returns400()
TestAuth.User()));
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
- await AssertProblemDetailsAsync(response, 400, ErrorMessages.TwoFactor.VerificationFailed);
+ await ProblemDetailsAssert.MatchesAsync(response, 400, ErrorMessages.TwoFactor.VerificationFailed);
}
[Fact]
@@ -747,7 +736,7 @@ public async Task TwoFactorVerifySetup_Unauthenticated_Returns401()
JsonContent.Create(new { Code = "123456" })));
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
- await AssertProblemDetailsAsync(response, 401, ErrorMessages.Auth.NotAuthenticated);
+ await ProblemDetailsAssert.MatchesAsync(response, 401, ErrorMessages.Auth.NotAuthenticated);
}
#endregion
@@ -776,7 +765,7 @@ public async Task TwoFactorDisable_Unauthenticated_Returns401()
JsonContent.Create(new { Password = "Password1!" })));
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
- await AssertProblemDetailsAsync(response, 401, ErrorMessages.Auth.NotAuthenticated);
+ await ProblemDetailsAssert.MatchesAsync(response, 401, ErrorMessages.Auth.NotAuthenticated);
}
[Fact]
@@ -791,7 +780,7 @@ public async Task TwoFactorDisable_ServiceFailure_Returns400()
TestAuth.User()));
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
- await AssertProblemDetailsAsync(response, 400, ErrorMessages.TwoFactor.NotEnabled);
+ await ProblemDetailsAssert.MatchesAsync(response, 400, ErrorMessages.TwoFactor.NotEnabled);
}
#endregion
@@ -824,7 +813,7 @@ public async Task TwoFactorRegenerateCodes_Unauthenticated_Returns401()
JsonContent.Create(new { Password = "Password1!" })));
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
- await AssertProblemDetailsAsync(response, 401, ErrorMessages.Auth.NotAuthenticated);
+ await ProblemDetailsAssert.MatchesAsync(response, 401, ErrorMessages.Auth.NotAuthenticated);
}
[Fact]
@@ -839,7 +828,7 @@ public async Task TwoFactorRegenerateCodes_ServiceFailure_Returns400()
TestAuth.User()));
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
- await AssertProblemDetailsAsync(response, 400, ErrorMessages.TwoFactor.NotEnabled);
+ await ProblemDetailsAssert.MatchesAsync(response, 400, ErrorMessages.TwoFactor.NotEnabled);
}
#endregion
diff --git a/templates/src/backend/tests/MyProject.Api.Tests/Controllers/ExternalAuthControllerTests.cs b/templates/src/backend/tests/MyProject.Api.Tests/Controllers/ExternalAuthControllerTests.cs
index 8bef4f2..0600f10 100644
--- a/templates/src/backend/tests/MyProject.Api.Tests/Controllers/ExternalAuthControllerTests.cs
+++ b/templates/src/backend/tests/MyProject.Api.Tests/Controllers/ExternalAuthControllerTests.cs
@@ -1,6 +1,5 @@
using System.Net;
using System.Net.Http.Json;
-using System.Text.Json;
using MyProject.Api.Tests.Contracts;
using MyProject.Api.Tests.Fixtures;
using MyProject.Application.Features.Authentication.Dtos;
@@ -36,16 +35,6 @@ private static HttpRequestMessage Get(string url, string? auth = null)
return msg;
}
- private static async Task AssertProblemDetailsAsync(
- HttpResponseMessage response, int expectedStatus, string? expectedDetail = null)
- {
- var json = await response.Content.ReadFromJsonAsync();
- Assert.Equal(expectedStatus, json.GetProperty("status").GetInt32());
- if (expectedDetail is not null)
- {
- Assert.Equal(expectedDetail, json.GetProperty("detail").GetString());
- }
- }
#region GetProviders
@@ -118,7 +107,7 @@ public async Task ExternalChallenge_InvalidProvider_Returns400()
JsonContent.Create(new { Provider = "Unknown", RedirectUri = "https://example.com/oauth/callback" })));
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
- await AssertProblemDetailsAsync(response, 400, ErrorMessages.ExternalAuth.ProviderNotConfigured);
+ await ProblemDetailsAssert.MatchesAsync(response, 400, ErrorMessages.ExternalAuth.ProviderNotConfigured);
}
[Fact]
@@ -223,7 +212,7 @@ public async Task ExternalCallback_InvalidState_Returns400()
JsonContent.Create(new { Code = "auth-code", State = "bad-state" })));
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
- await AssertProblemDetailsAsync(response, 400, ErrorMessages.ExternalAuth.InvalidState);
+ await ProblemDetailsAssert.MatchesAsync(response, 400, ErrorMessages.ExternalAuth.InvalidState);
}
[Fact]
@@ -269,7 +258,7 @@ public async Task ExternalUnlink_LastAuthMethod_Returns400()
TestAuth.User()));
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
- await AssertProblemDetailsAsync(response, 400, ErrorMessages.ExternalAuth.CannotUnlinkLastMethod);
+ await ProblemDetailsAssert.MatchesAsync(response, 400, ErrorMessages.ExternalAuth.CannotUnlinkLastMethod);
}
[Fact]
@@ -315,7 +304,7 @@ public async Task SetPassword_AlreadyHasPassword_Returns400()
TestAuth.User()));
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
- await AssertProblemDetailsAsync(response, 400, ErrorMessages.ExternalAuth.PasswordAlreadySet);
+ await ProblemDetailsAssert.MatchesAsync(response, 400, ErrorMessages.ExternalAuth.PasswordAlreadySet);
}
[Fact]
diff --git a/templates/src/backend/tests/MyProject.Api.Tests/Controllers/OAuthProvidersControllerTests.cs b/templates/src/backend/tests/MyProject.Api.Tests/Controllers/OAuthProvidersControllerTests.cs
index 8022fad..4d860e2 100644
--- a/templates/src/backend/tests/MyProject.Api.Tests/Controllers/OAuthProvidersControllerTests.cs
+++ b/templates/src/backend/tests/MyProject.Api.Tests/Controllers/OAuthProvidersControllerTests.cs
@@ -1,6 +1,5 @@
using System.Net;
using System.Net.Http.Json;
-using System.Text.Json;
using MyProject.Api.Tests.Contracts;
using MyProject.Api.Tests.Fixtures;
using MyProject.Application.Features.Authentication.Dtos;
@@ -44,16 +43,6 @@ private static HttpRequestMessage Post(string url, string auth)
return request;
}
- private static async Task AssertProblemDetailsAsync(
- HttpResponseMessage response, int expectedStatus, string? expectedDetail = null)
- {
- var json = await response.Content.ReadFromJsonAsync();
- Assert.Equal(expectedStatus, json.GetProperty("status").GetInt32());
- if (expectedDetail is not null)
- {
- Assert.Equal(expectedDetail, json.GetProperty("detail").GetString());
- }
- }
#region ListProviders
@@ -140,7 +129,7 @@ public async Task UpdateProvider_UnknownProvider_Returns400()
JsonContent.Create(new { IsEnabled = true, ClientId = "id", ClientSecret = "secret" })));
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
- await AssertProblemDetailsAsync(response, 400, "The specified authentication provider is not recognized.");
+ await ProblemDetailsAssert.MatchesAsync(response, 400, ErrorMessages.ExternalAuth.UnknownProvider);
}
[Fact]
@@ -217,8 +206,7 @@ public async Task UpdateProvider_EnabledWithoutSecretAndNoExisting_Returns400()
JsonContent.Create(new { IsEnabled = true, ClientId = "new-id" })));
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
- await AssertProblemDetailsAsync(response, 400,
- "A client secret is required when enabling a provider that has no existing secret.");
+ await ProblemDetailsAssert.MatchesAsync(response, 400, ErrorMessages.ExternalAuth.ClientSecretRequired);
}
[Fact]
@@ -264,7 +252,7 @@ public async Task TestConnection_InvalidCredentials_Returns400()
TestAuth.WithPermissions(AppPermissions.OAuthProviders.Manage)));
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
- await AssertProblemDetailsAsync(response, 400,
+ await ProblemDetailsAssert.MatchesAsync(response, 400,
ErrorMessages.ExternalAuth.TestConnectionInvalidCredentials);
}
@@ -280,7 +268,7 @@ public async Task TestConnection_NotConfigured_Returns400()
TestAuth.WithPermissions(AppPermissions.OAuthProviders.Manage)));
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
- await AssertProblemDetailsAsync(response, 400,
+ await ProblemDetailsAssert.MatchesAsync(response, 400,
ErrorMessages.ExternalAuth.TestConnectionNotConfigured);
}
diff --git a/templates/src/backend/tests/MyProject.Api.Tests/Controllers/UsersControllerTests.cs b/templates/src/backend/tests/MyProject.Api.Tests/Controllers/UsersControllerTests.cs
index ea97ca2..43afe12 100644
--- a/templates/src/backend/tests/MyProject.Api.Tests/Controllers/UsersControllerTests.cs
+++ b/templates/src/backend/tests/MyProject.Api.Tests/Controllers/UsersControllerTests.cs
@@ -1,7 +1,6 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
-using System.Text.Json;
using MyProject.Api.Tests.Contracts;
using MyProject.Api.Tests.Fixtures;
// @feature audit
@@ -71,16 +70,6 @@ private static MultipartFormDataContent CreateAvatarUpload(
}
// @end
- private static async Task AssertProblemDetailsAsync(
- HttpResponseMessage response, int expectedStatus, string? expectedDetail = null)
- {
- var json = await response.Content.ReadFromJsonAsync();
- Assert.Equal(expectedStatus, json.GetProperty("status").GetInt32());
- if (expectedDetail is not null)
- {
- Assert.Equal(expectedDetail, json.GetProperty("detail").GetString());
- }
- }
#region GetMe
@@ -109,7 +98,7 @@ public async Task GetMe_Unauthenticated_Returns401()
var response = await _client.SendAsync(Get("/api/users/me"));
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
- await AssertProblemDetailsAsync(response, 401, ErrorMessages.Auth.NotAuthenticated);
+ await ProblemDetailsAssert.MatchesAsync(response, 401, ErrorMessages.Auth.NotAuthenticated);
}
[Fact]
@@ -121,7 +110,7 @@ public async Task GetMe_ServiceFailure_Returns400WithProblemDetails()
var response = await _client.SendAsync(Get("/api/users/me", TestAuth.User()));
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
- await AssertProblemDetailsAsync(response, 400, ErrorMessages.User.NotFound);
+ await ProblemDetailsAssert.MatchesAsync(response, 400, ErrorMessages.User.NotFound);
}
#endregion
@@ -198,7 +187,7 @@ public async Task UploadAvatar_ServiceFailure_Returns400()
Put("/api/users/me/avatar", CreateAvatarUpload(), TestAuth.User()));
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
- await AssertProblemDetailsAsync(response, 400, ErrorMessages.Avatar.FileTooLarge);
+ await ProblemDetailsAssert.MatchesAsync(response, 400, ErrorMessages.Avatar.FileTooLarge);
}
#endregion
@@ -264,7 +253,7 @@ public async Task GetAvatar_NotFound_Returns404()
Get($"/api/users/{userId}/avatar", TestAuth.User()));
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
- await AssertProblemDetailsAsync(response, 404, ErrorMessages.Avatar.NotFound);
+ await ProblemDetailsAssert.MatchesAsync(response, 404, ErrorMessages.Avatar.NotFound);
}
[Fact]
@@ -316,7 +305,7 @@ public async Task DeleteMe_ServiceFailure_Returns400WithProblemDetails()
Delete("/api/users/me", JsonContent.Create(new { Password = "WrongPass1!" }), TestAuth.User()));
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
- await AssertProblemDetailsAsync(response, 400, ErrorMessages.User.DeleteInvalidPassword);
+ await ProblemDetailsAssert.MatchesAsync(response, 400, ErrorMessages.User.DeleteInvalidPassword);
}
#endregion
diff --git a/templates/src/backend/tests/MyProject.Api.Tests/Fixtures/ProblemDetailsAssert.cs b/templates/src/backend/tests/MyProject.Api.Tests/Fixtures/ProblemDetailsAssert.cs
new file mode 100644
index 0000000..acdf736
--- /dev/null
+++ b/templates/src/backend/tests/MyProject.Api.Tests/Fixtures/ProblemDetailsAssert.cs
@@ -0,0 +1,38 @@
+using System.Net.Http.Json;
+using System.Text.Json;
+using MyProject.Shared;
+
+namespace MyProject.Api.Tests.Fixtures;
+
+///
+/// Shared assertions for ProblemDetails (RFC 9457) responses.
+///
+public static class ProblemDetailsAssert
+{
+ ///
+ /// Asserts that the response body is a ProblemDetails with the expected status and, when
+ /// is given, the expected detail and code.
+ ///
+ public static async Task MatchesAsync(HttpResponseMessage response, int expectedStatus, Error? expectedError = null)
+ {
+ var json = await response.Content.ReadFromJsonAsync();
+ Assert.Equal(expectedStatus, json.GetProperty("status").GetInt32());
+
+ if (expectedError is not null)
+ {
+ Assert.Equal(expectedError.Message, json.GetProperty("detail").GetString());
+ Assert.Equal(expectedError.Code, json.GetProperty("code").GetString());
+ }
+ }
+
+ ///
+ /// Asserts that the response body is a ProblemDetails with the expected status and code.
+ /// Use for framework-generated bodies (validation, status code pages) that have no ErrorMessages entry.
+ ///
+ public static async Task HasCodeAsync(HttpResponseMessage response, int expectedStatus, string expectedCode)
+ {
+ var json = await response.Content.ReadFromJsonAsync();
+ Assert.Equal(expectedStatus, json.GetProperty("status").GetInt32());
+ Assert.Equal(expectedCode, json.GetProperty("code").GetString());
+ }
+}
diff --git a/templates/src/backend/tests/MyProject.Api.Tests/Middlewares/OriginValidationMiddlewareTests.cs b/templates/src/backend/tests/MyProject.Api.Tests/Middlewares/OriginValidationMiddlewareTests.cs
index a739bfa..d503c49 100644
--- a/templates/src/backend/tests/MyProject.Api.Tests/Middlewares/OriginValidationMiddlewareTests.cs
+++ b/templates/src/backend/tests/MyProject.Api.Tests/Middlewares/OriginValidationMiddlewareTests.cs
@@ -1,6 +1,4 @@
using System.Net;
-using System.Net.Http.Json;
-using System.Text.Json;
using MyProject.Api.Tests.Fixtures;
using MyProject.Shared;
@@ -127,9 +125,7 @@ public async Task PostRequest_WithDisallowedOrigin_ReturnsProblemDetails()
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.MediaType);
- var json = await response.Content.ReadFromJsonAsync();
- Assert.Equal(403, json.GetProperty("status").GetInt32());
- Assert.Equal(ErrorMessages.Security.CrossOriginRequestBlocked, json.GetProperty("detail").GetString());
+ await ProblemDetailsAssert.MatchesAsync(response, 403, ErrorMessages.Security.CrossOriginRequestBlocked);
}
// ── Origin case insensitivity ───────────────────────────────────
diff --git a/templates/src/backend/tests/MyProject.Api.Tests/Middlewares/ProblemDetailsCodeTests.cs b/templates/src/backend/tests/MyProject.Api.Tests/Middlewares/ProblemDetailsCodeTests.cs
new file mode 100644
index 0000000..5656e60
--- /dev/null
+++ b/templates/src/backend/tests/MyProject.Api.Tests/Middlewares/ProblemDetailsCodeTests.cs
@@ -0,0 +1,117 @@
+using System.Net;
+using System.Net.Http.Json;
+using MyProject.Api.Tests.Fixtures;
+using MyProject.Application.Features.Authentication.Dtos;
+using MyProject.Shared;
+using MyProject.WebApi.Shared;
+using NSubstitute.ExceptionExtensions;
+
+namespace MyProject.Api.Tests.Middlewares;
+
+///
+/// Verifies that every ProblemDetails response carries a machine-readable code extension,
+/// regardless of which part of the pipeline produced it (controller, authorization handler,
+/// exception middleware, model validation, status code pages).
+///
+public class ProblemDetailsCodeTests : IClassFixture, IDisposable
+{
+ private readonly CustomWebApplicationFactory _factory;
+ private readonly HttpClient _client;
+
+ public ProblemDetailsCodeTests(CustomWebApplicationFactory factory)
+ {
+ _factory = factory;
+ _factory.ResetMocks();
+ _client = factory.CreateClient();
+ }
+
+ public void Dispose() => _client.Dispose();
+
+ [Fact]
+ public async Task ControllerFailure_IncludesErrorCode()
+ {
+ _factory.UserService.GetCurrentUserAsync(Arg.Any())
+ .Returns(Result.Failure(ErrorMessages.User.NotFound, ErrorType.NotFound));
+
+ var request = new HttpRequestMessage(HttpMethod.Get, "/api/users/me");
+ request.Headers.Add("Authorization", TestAuth.User());
+
+ var response = await _client.SendAsync(request);
+
+ Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
+ await ProblemDetailsAssert.MatchesAsync(response, 404, ErrorMessages.User.NotFound);
+ }
+
+ [Fact]
+ public async Task Unauthenticated_IncludesErrorCode()
+ {
+ var response = await _client.GetAsync("/api/users/me");
+
+ Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+ await ProblemDetailsAssert.MatchesAsync(response, 401, ErrorMessages.Auth.NotAuthenticated);
+ }
+
+ // @feature admin
+ [Fact]
+ public async Task Forbidden_IncludesErrorCode()
+ {
+ var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/admin/users?pageNumber=1&pageSize=10");
+ request.Headers.Add("Authorization", TestAuth.User());
+
+ var response = await _client.SendAsync(request);
+
+ Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
+ await ProblemDetailsAssert.MatchesAsync(response, 403, ErrorMessages.Auth.InsufficientPermissions);
+ }
+
+ // @end
+ [Fact]
+ public async Task ModelValidationFailure_IncludesValidationFailedCode()
+ {
+ var response = await _client.PostAsync(
+ "/api/auth/login",
+ JsonContent.Create(new { Username = "", Password = "" }));
+
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ await ProblemDetailsAssert.HasCodeAsync(response, 400, ProblemFactory.ValidationFailedCode);
+ }
+
+ [Fact]
+ public async Task UnmatchedRoute_IncludesFallbackCode()
+ {
+ var response = await _client.GetAsync("/api/does-not-exist");
+
+ Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
+ await ProblemDetailsAssert.HasCodeAsync(response, 404, "not_found");
+ }
+
+ [Fact]
+ public async Task UnhandledException_IncludesErrorCode()
+ {
+ _factory.UserService.GetCurrentUserAsync(Arg.Any())
+ .ThrowsAsync(new InvalidOperationException("boom"));
+
+ var request = new HttpRequestMessage(HttpMethod.Get, "/api/users/me");
+ request.Headers.Add("Authorization", TestAuth.User());
+
+ var response = await _client.SendAsync(request);
+
+ Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
+ await ProblemDetailsAssert.MatchesAsync(response, 500, ErrorMessages.Server.InternalError);
+ }
+
+ [Fact]
+ public async Task KeyNotFoundException_IncludesEntityNotFoundCode()
+ {
+ _factory.UserService.GetCurrentUserAsync(Arg.Any())
+ .ThrowsAsync(new KeyNotFoundException("missing"));
+
+ var request = new HttpRequestMessage(HttpMethod.Get, "/api/users/me");
+ request.Headers.Add("Authorization", TestAuth.User());
+
+ var response = await _client.SendAsync(request);
+
+ Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
+ await ProblemDetailsAssert.MatchesAsync(response, 404, ErrorMessages.Entity.NotFound);
+ }
+}
diff --git a/templates/src/backend/tests/MyProject.Api.Tests/Shared/ProblemFactoryTests.cs b/templates/src/backend/tests/MyProject.Api.Tests/Shared/ProblemFactoryTests.cs
new file mode 100644
index 0000000..162479e
--- /dev/null
+++ b/templates/src/backend/tests/MyProject.Api.Tests/Shared/ProblemFactoryTests.cs
@@ -0,0 +1,94 @@
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using MyProject.Shared;
+using MyProject.WebApi.Shared;
+
+namespace MyProject.Api.Tests.Shared;
+
+public class ProblemFactoryTests
+{
+ private static readonly Error TestError = new("test_error", "Something went wrong.");
+
+ [Theory]
+ [InlineData(ErrorType.Validation, 400)]
+ [InlineData(ErrorType.Unauthorized, 401)]
+ [InlineData(ErrorType.Forbidden, 403)]
+ [InlineData(ErrorType.NotFound, 404)]
+ [InlineData(null, 400)]
+ public void Create_MapsErrorTypeToStatusAndCarriesDetailAndCode(ErrorType? errorType, int expectedStatus)
+ {
+ var result = ProblemFactory.Create(TestError, errorType);
+
+ var problem = Assert.IsType(result.Value);
+ Assert.Equal(expectedStatus, result.StatusCode);
+ Assert.Equal(expectedStatus, problem.Status);
+ Assert.Equal(TestError.Message, problem.Detail);
+ Assert.Equal(TestError.Code, problem.Extensions[ProblemFactory.CodeExtensionKey]);
+ }
+
+ [Fact]
+ public void Create_WithNullError_OmitsDetailAndCode()
+ {
+ var result = ProblemFactory.Create(null, ErrorType.NotFound);
+
+ var problem = Assert.IsType(result.Value);
+ Assert.Equal(404, problem.Status);
+ Assert.Null(problem.Detail);
+ Assert.False(problem.Extensions.ContainsKey(ProblemFactory.CodeExtensionKey));
+ }
+
+ [Fact]
+ public void CreateProblemDetails_CarriesStatusDetailAndCode()
+ {
+ var problem = ProblemFactory.CreateProblemDetails(TestError, StatusCodes.Status429TooManyRequests);
+
+ Assert.Equal(429, problem.Status);
+ Assert.Equal(TestError.Message, problem.Detail);
+ Assert.Equal(TestError.Code, problem.Extensions[ProblemFactory.CodeExtensionKey]);
+ }
+
+ [Fact]
+ public void EnsureCode_PreservesExistingCode()
+ {
+ var problem = ProblemFactory.CreateProblemDetails(TestError, StatusCodes.Status400BadRequest);
+
+ ProblemFactory.EnsureCode(problem);
+
+ Assert.Equal(TestError.Code, problem.Extensions[ProblemFactory.CodeExtensionKey]);
+ }
+
+ [Fact]
+ public void EnsureCode_ValidationProblem_UsesValidationFailedCode()
+ {
+ var problem = new HttpValidationProblemDetails { Status = StatusCodes.Status400BadRequest };
+
+ ProblemFactory.EnsureCode(problem);
+
+ Assert.Equal(ProblemFactory.ValidationFailedCode, problem.Extensions[ProblemFactory.CodeExtensionKey]);
+ }
+
+ [Theory]
+ [InlineData(400, "bad_request")]
+ [InlineData(404, "not_found")]
+ [InlineData(405, "method_not_allowed")]
+ [InlineData(415, "unsupported_media_type")]
+ [InlineData(500, "internal_server_error")]
+ public void EnsureCode_WithoutCode_FallsBackToSnakeCaseReasonPhrase(int status, string expectedCode)
+ {
+ var problem = new ProblemDetails { Status = status };
+
+ ProblemFactory.EnsureCode(problem);
+
+ Assert.Equal(expectedCode, problem.Extensions[ProblemFactory.CodeExtensionKey]);
+ }
+
+ [Fact]
+ public void EnsureCode_WithoutStatus_TreatsAsInternalServerError()
+ {
+ var problem = new ProblemDetails();
+
+ ProblemFactory.EnsureCode(problem);
+
+ Assert.Equal("internal_server_error", problem.Extensions[ProblemFactory.CodeExtensionKey]);
+ }
+}
diff --git a/templates/src/backend/tests/MyProject.Component.Tests/Extensions/EmailServiceRegistrationTests.cs b/templates/src/backend/tests/MyProject.Component.Tests/Extensions/EmailServiceRegistrationTests.cs
new file mode 100644
index 0000000..75dbbbb
--- /dev/null
+++ b/templates/src/backend/tests/MyProject.Component.Tests/Extensions/EmailServiceRegistrationTests.cs
@@ -0,0 +1,86 @@
+// @feature jobs
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using MyProject.Application.Features.Email;
+using MyProject.Infrastructure.Features.Email.Extensions;
+using MyProject.Infrastructure.Features.Email.Jobs;
+using MyProject.Infrastructure.Features.Email.Services;
+
+namespace MyProject.Component.Tests.Extensions;
+
+public class EmailServiceRegistrationTests
+{
+ private static ServiceCollection Register(bool emailEnabled, bool? jobSchedulingEnabled)
+ {
+ var settings = new Dictionary
+ {
+ ["Email:Enabled"] = emailEnabled.ToString(),
+ ["Email:FrontendBaseUrl"] = "https://test.example.com",
+ ["Email:Smtp:Host"] = "localhost"
+ };
+
+ if (jobSchedulingEnabled is not null)
+ {
+ settings["JobScheduling:Enabled"] = jobSchedulingEnabled.Value.ToString();
+ }
+
+ var configuration = new ConfigurationBuilder()
+ .AddInMemoryCollection(settings)
+ .Build();
+
+ var services = new ServiceCollection();
+ services.AddEmailServices(configuration);
+ return services;
+ }
+
+ private static Type? ImplementationOf(ServiceCollection services) =>
+ services.Single(d => d.ServiceType == typeof(TService)).ImplementationType;
+
+ [Fact]
+ public void AddEmailServices_EmailAndJobsEnabled_RegistersBackgroundEmailService()
+ {
+ var services = Register(emailEnabled: true, jobSchedulingEnabled: true);
+
+ Assert.Equal(typeof(BackgroundEmailService), ImplementationOf(services));
+ Assert.Contains(services, d => d.ServiceType == typeof(EmailDeliveryJob));
+ Assert.Contains(services, d => d.ServiceType == typeof(SmtpEmailService));
+ }
+
+ [Fact]
+ public void AddEmailServices_EmailEnabledJobsDefault_RegistersBackgroundEmailService()
+ {
+ // JobScheduling:Enabled defaults to true when the section is absent.
+ var services = Register(emailEnabled: true, jobSchedulingEnabled: null);
+
+ Assert.Equal(typeof(BackgroundEmailService), ImplementationOf(services));
+ }
+
+ [Fact]
+ public void AddEmailServices_EmailEnabledJobsDisabled_RegistersSmtpEmailService()
+ {
+ var services = Register(emailEnabled: true, jobSchedulingEnabled: false);
+
+ Assert.Equal(typeof(SmtpEmailService), ImplementationOf(services));
+ Assert.DoesNotContain(services, d => d.ServiceType == typeof(EmailDeliveryJob));
+ }
+
+ [Fact]
+ public void AddEmailServices_EmailDisabled_RegistersNoOpEmailService()
+ {
+ var services = Register(emailEnabled: false, jobSchedulingEnabled: true);
+
+ Assert.Equal(typeof(NoOpEmailService), ImplementationOf(services));
+ Assert.DoesNotContain(services, d => d.ServiceType == typeof(EmailDeliveryJob));
+ Assert.DoesNotContain(services, d => d.ServiceType == typeof(SmtpEmailService));
+ }
+
+ [Fact]
+ public void AddEmailServices_AlwaysRegistersTemplatePipeline()
+ {
+ var services = Register(emailEnabled: false, jobSchedulingEnabled: false);
+
+ Assert.Equal(typeof(FluidEmailTemplateRenderer), ImplementationOf(services));
+ Assert.Equal(typeof(TemplatedEmailSender), ImplementationOf(services));
+ }
+}
+// @end
diff --git a/templates/src/backend/tests/MyProject.Component.Tests/Services/AuthenticationServiceTests.cs b/templates/src/backend/tests/MyProject.Component.Tests/Services/AuthenticationServiceTests.cs
index 95c2c2f..4ff88a4 100644
--- a/templates/src/backend/tests/MyProject.Component.Tests/Services/AuthenticationServiceTests.cs
+++ b/templates/src/backend/tests/MyProject.Component.Tests/Services/AuthenticationServiceTests.cs
@@ -399,7 +399,8 @@ public async Task Register_DuplicateEmail_ReturnsFailure()
var result = await _sut.Register(input);
Assert.True(result.IsFailure);
- Assert.Contains("Duplicate email", result.Error);
+ Assert.Equal(ErrorMessages.Auth.RegistrationInvalid.Code, result.Error?.Code);
+ Assert.Contains("Duplicate email", result.Error?.Message);
}
[Fact]
@@ -888,7 +889,8 @@ public async Task ChangePassword_IdentityFails_ReturnsFailure()
var result = await _sut.ChangePasswordAsync(new ChangePasswordInput("current", "newPass1!"));
Assert.True(result.IsFailure);
- Assert.Contains("Password too common", result.Error);
+ Assert.Equal(ErrorMessages.Auth.PasswordPolicyViolation.Code, result.Error?.Code);
+ Assert.Contains("Password too common", result.Error?.Message);
}
#endregion
@@ -1177,7 +1179,8 @@ public async Task ResetPassword_PasswordPolicyFailure_ReturnsDescriptiveError()
new ResetPasswordInput(rawToken, "weak"));
Assert.True(result.IsFailure);
- Assert.Contains("Password too short", result.Error);
+ Assert.Equal(ErrorMessages.Auth.PasswordPolicyViolation.Code, result.Error?.Code);
+ Assert.Contains("Password too short", result.Error?.Message);
}
#endregion
diff --git a/templates/src/backend/tests/MyProject.Component.Tests/Services/BackgroundEmailServiceTests.cs b/templates/src/backend/tests/MyProject.Component.Tests/Services/BackgroundEmailServiceTests.cs
new file mode 100644
index 0000000..0b1802f
--- /dev/null
+++ b/templates/src/backend/tests/MyProject.Component.Tests/Services/BackgroundEmailServiceTests.cs
@@ -0,0 +1,79 @@
+// @feature jobs
+using Hangfire;
+using Hangfire.Common;
+using Hangfire.States;
+using Hangfire.Storage;
+using Microsoft.Extensions.Logging;
+using MyProject.Application.Features.Email;
+using MyProject.Infrastructure.Features.Email.Jobs;
+using MyProject.Infrastructure.Features.Email.Services;
+
+namespace MyProject.Component.Tests.Services;
+
+public class BackgroundEmailServiceTests
+{
+ private readonly IBackgroundJobClient _backgroundJobClient = Substitute.For();
+ private readonly BackgroundEmailService _sut;
+
+ public BackgroundEmailServiceTests()
+ {
+ _sut = new BackgroundEmailService(_backgroundJobClient, Substitute.For>());
+ }
+
+ private static EmailMessage CreateMessage() =>
+ new("user@test.com", "Subject", "body", "plain text");
+
+ [Fact]
+ public async Task SendEmailAsync_EnqueuesEmailDeliveryJobWithMessage()
+ {
+ var message = CreateMessage();
+
+ await _sut.SendEmailAsync(message, CancellationToken.None);
+
+ _backgroundJobClient.Received(1).Create(
+ Arg.Is(job =>
+ job.Type == typeof(EmailDeliveryJob) &&
+ job.Method.Name == nameof(EmailDeliveryJob.ExecuteAsync) &&
+ ReferenceEquals(job.Args[0], message)),
+ Arg.Any());
+ }
+
+ [Fact]
+ public async Task SendEmailAsync_DoesNotSendInline()
+ {
+ await _sut.SendEmailAsync(CreateMessage(), CancellationToken.None);
+
+ // Only the job creation call may hit the client - nothing else is invoked synchronously.
+ Assert.Single(_backgroundJobClient.ReceivedCalls());
+ }
+
+ [Fact]
+ public async Task SendEmailAsync_CancellationRequested_ThrowsAndDoesNotEnqueue()
+ {
+ using var cts = new CancellationTokenSource();
+ await cts.CancelAsync();
+
+ await Assert.ThrowsAnyAsync(() =>
+ _sut.SendEmailAsync(CreateMessage(), cts.Token));
+
+ _backgroundJobClient.DidNotReceiveWithAnyArgs().Create(default!, default!);
+ }
+
+ [Fact]
+ public async Task SendEmailAsync_EnqueuedJob_RoundTripsThroughHangfireSerialization()
+ {
+ var message = CreateMessage();
+ Job? capturedJob = null;
+ _backgroundJobClient.Create(Arg.Do(job => capturedJob = job), Arg.Any());
+
+ await _sut.SendEmailAsync(message, CancellationToken.None);
+
+ Assert.NotNull(capturedJob);
+ var deserialized = InvocationData.SerializeJob(capturedJob).DeserializeJob();
+
+ Assert.Equal(typeof(EmailDeliveryJob), deserialized.Type);
+ Assert.Equal(message, Assert.IsType(deserialized.Args[0]));
+ Assert.Equal(CancellationToken.None, deserialized.Args[1]);
+ }
+}
+// @end
diff --git a/templates/src/backend/tests/MyProject.Component.Tests/Services/EmailDeliveryJobTests.cs b/templates/src/backend/tests/MyProject.Component.Tests/Services/EmailDeliveryJobTests.cs
new file mode 100644
index 0000000..ce45e0a
--- /dev/null
+++ b/templates/src/backend/tests/MyProject.Component.Tests/Services/EmailDeliveryJobTests.cs
@@ -0,0 +1,82 @@
+// @feature jobs
+using System.Reflection;
+using Hangfire;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using MyProject.Application.Features.Email;
+using MyProject.Infrastructure.Features.Email.Jobs;
+using MyProject.Infrastructure.Features.Email.Options;
+using MyProject.Infrastructure.Features.Email.Services;
+
+namespace MyProject.Component.Tests.Services;
+
+public class EmailDeliveryJobTests
+{
+ private static readonly MethodInfo ExecuteMethod =
+ typeof(EmailDeliveryJob).GetMethod(nameof(EmailDeliveryJob.ExecuteAsync))!;
+
+ private static EmailDeliveryJob CreateJob()
+ {
+ var options = Options.Create(new EmailOptions
+ {
+ Enabled = true,
+ Smtp = new EmailOptions.SmtpOptions
+ {
+ Host = "invalid.host.test",
+ Port = 1025,
+ UseSsl = false
+ }
+ });
+
+ var smtpEmailService = new SmtpEmailService(options, Substitute.For>());
+ return new EmailDeliveryJob(smtpEmailService);
+ }
+
+ private static EmailMessage CreateMessage() =>
+ new("user@test.com", "Subject", "body");
+
+ [Fact]
+ public void ExecuteAsync_HasAutomaticRetryWithBackoff()
+ {
+ var retry = ExecuteMethod.GetCustomAttribute();
+
+ Assert.NotNull(retry);
+ Assert.Equal(EmailDeliveryJob.RetryAttempts, retry.Attempts);
+ Assert.True(retry.Attempts > 0);
+ Assert.Equal(retry.Attempts, retry.DelaysInSeconds.Length);
+ Assert.Equal(AttemptsExceededAction.Fail, retry.OnAttemptsExceeded);
+ }
+
+ [Fact]
+ public void ExecuteAsync_RetryDelays_AreIncreasing()
+ {
+ var delays = ExecuteMethod.GetCustomAttribute()!.DelaysInSeconds;
+
+ Assert.All(delays, delay => Assert.True(delay > 0));
+ Assert.Equal(delays.OrderBy(d => d), delays);
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_SmtpFailure_PropagatesExceptionForRetry()
+ {
+ var job = CreateJob();
+
+ var exception = await Record.ExceptionAsync(() =>
+ job.ExecuteAsync(CreateMessage(), CancellationToken.None));
+
+ Assert.NotNull(exception);
+ Assert.IsNotType(exception);
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_CancelledToken_ThrowsOperationCancelled()
+ {
+ var job = CreateJob();
+ using var cts = new CancellationTokenSource();
+ await cts.CancelAsync();
+
+ await Assert.ThrowsAnyAsync(() =>
+ job.ExecuteAsync(CreateMessage(), cts.Token));
+ }
+}
+// @end
diff --git a/templates/src/backend/tests/MyProject.Component.Tests/Services/UserServiceTests.cs b/templates/src/backend/tests/MyProject.Component.Tests/Services/UserServiceTests.cs
index 14aa4bd..9e50ad1 100644
--- a/templates/src/backend/tests/MyProject.Component.Tests/Services/UserServiceTests.cs
+++ b/templates/src/backend/tests/MyProject.Component.Tests/Services/UserServiceTests.cs
@@ -419,7 +419,7 @@ public async Task UploadAvatar_ProcessingFails_ReturnsFailure()
var result = await _sut.UploadAvatarAsync([0xFF], "photo.exe", CancellationToken.None);
Assert.True(result.IsFailure);
- Assert.Contains("Unsupported", result.Error);
+ Assert.Equal(ErrorMessages.Avatar.UnsupportedFormat, result.Error);
await _fileStorageService.DidNotReceive()
.UploadAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any());
}
@@ -435,7 +435,7 @@ public async Task UploadAvatar_StorageFails_ReturnsFailure()
_imageProcessingService.ProcessAvatar(Arg.Any(), Arg.Any(), Arg.Any())
.Returns(Result.Success(processed));
_fileStorageService.UploadAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any())
- .Returns(Result.Failure("S3 error"));
+ .Returns(Result.Failure(ErrorMessages.FileStorage.UploadFailed));
var result = await _sut.UploadAvatarAsync([0xFF], "photo.jpg", CancellationToken.None);
@@ -542,7 +542,7 @@ public async Task RemoveAvatar_StorageDeleteFails_StillClearsFlag()
_userManager.UpdateAsync(user).Returns(IdentityResult.Success);
_userManager.GetRolesAsync(user).Returns(new List { "User" });
_fileStorageService.DeleteAsync(Arg.Any(), Arg.Any())
- .Returns(Result.Failure("S3 error"));
+ .Returns(Result.Failure(ErrorMessages.FileStorage.DeleteFailed));
var result = await _sut.RemoveAvatarAsync(CancellationToken.None);
@@ -615,7 +615,7 @@ public async Task GetAvatar_StorageFailure_ReturnsFailure()
var user = new ApplicationUser { Id = _userId, UserName = "test@example.com", HasAvatar = true };
_userManager.FindByIdAsync(_userId.ToString()).Returns(user);
_fileStorageService.DownloadAsync($"avatars/{_userId}.webp", Arg.Any())
- .Returns(Result.Failure("Storage error"));
+ .Returns(Result.Failure(ErrorMessages.FileStorage.DownloadFailed));
var result = await _sut.GetAvatarAsync(_userId, CancellationToken.None);
@@ -657,7 +657,7 @@ public async Task DeleteAccount_AvatarCleanupFails_StillDeletesAccount()
_userManager.GetRolesAsync(user).Returns(new List { "User" });
_userManager.DeleteAsync(user).Returns(IdentityResult.Success);
_fileStorageService.DeleteAsync(Arg.Any(), Arg.Any())
- .Returns(Result.Failure("S3 error"));
+ .Returns(Result.Failure(ErrorMessages.FileStorage.DeleteFailed));
var result = await _sut.DeleteAccountAsync(new DeleteAccountInput("correct"));
diff --git a/templates/src/backend/tests/MyProject.Unit.Tests/Shared/ErrorMessagesTests.cs b/templates/src/backend/tests/MyProject.Unit.Tests/Shared/ErrorMessagesTests.cs
index 2b525e0..0b8ab5e 100644
--- a/templates/src/backend/tests/MyProject.Unit.Tests/Shared/ErrorMessagesTests.cs
+++ b/templates/src/backend/tests/MyProject.Unit.Tests/Shared/ErrorMessagesTests.cs
@@ -1,9 +1,10 @@
using System.Reflection;
+using System.Text.RegularExpressions;
using MyProject.Shared;
namespace MyProject.Unit.Tests.Shared;
-public class ErrorMessagesTests
+public partial class ErrorMessagesTests
{
private static readonly string[] ExpectedNestedClasses =
[
@@ -21,12 +22,43 @@ public class ErrorMessagesTests
// @feature avatars
"Avatar",
// @end
+ // @feature file-storage
+ "FileStorage",
+ // @end
// @feature oauth
"ExternalAuth",
// @end
"Entity"
];
+ [GeneratedRegex("^[a-z][a-z0-9]*(_[a-z0-9]+)*$")]
+ private static partial Regex SnakeCaseRegex();
+
+ [GeneratedRegex("(? AllErrors()
+ {
+ var nestedTypes = typeof(ErrorMessages)
+ .GetNestedTypes(BindingFlags.Public | BindingFlags.Static);
+
+ foreach (var type in nestedTypes)
+ {
+ var fields = type.GetFields(BindingFlags.Public | BindingFlags.Static)
+ .Where(f => f.IsInitOnly && f.FieldType == typeof(Error));
+
+ foreach (var field in fields)
+ {
+ var error = (Error?)field.GetValue(null);
+ Assert.NotNull(error);
+ yield return (type, field, error);
+ }
+ }
+ }
+
+ private static string ToSnakeCase(string pascalCase) =>
+ PascalBoundaryRegex().Replace(pascalCase, "_").ToLowerInvariant();
+
[Fact]
public void AllNestedClasses_ShouldExist()
{
@@ -42,67 +74,84 @@ public void AllNestedClasses_ShouldExist()
}
[Fact]
- public void AllConstStringFields_ShouldBeNonNullAndNonEmpty()
+ public void AllErrors_ShouldHaveNonEmptyMessage()
{
- var nestedTypes = typeof(ErrorMessages)
- .GetNestedTypes(BindingFlags.Public | BindingFlags.Static);
-
- foreach (var type in nestedTypes)
+ foreach (var (type, field, error) in AllErrors())
{
- var fields = type.GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy)
- .Where(f => f.IsLiteral && !f.IsInitOnly && f.FieldType == typeof(string));
-
- foreach (var field in fields)
- {
- var value = (string?)field.GetRawConstantValue();
- Assert.False(
- string.IsNullOrEmpty(value),
- $"ErrorMessages.{type.Name}.{field.Name} must not be null or empty.");
- }
+ Assert.False(
+ string.IsNullOrWhiteSpace(error.Message),
+ $"ErrorMessages.{type.Name}.{field.Name} must have a non-empty message.");
}
}
[Fact]
- public void EachNestedClass_ShouldHaveAtLeastOneConstant()
+ public void EachNestedClass_ShouldHaveAtLeastOneError()
{
var nestedTypes = typeof(ErrorMessages)
.GetNestedTypes(BindingFlags.Public | BindingFlags.Static);
foreach (var type in nestedTypes)
{
- var fields = type.GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy)
- .Where(f => f.IsLiteral && !f.IsInitOnly && f.FieldType == typeof(string))
+ var fields = type.GetFields(BindingFlags.Public | BindingFlags.Static)
+ .Where(f => f.IsInitOnly && f.FieldType == typeof(Error))
.ToList();
Assert.True(
fields.Count > 0,
- $"ErrorMessages.{type.Name} should have at least one const string field.");
+ $"ErrorMessages.{type.Name} should have at least one Error field.");
}
}
[Fact]
public void ErrorMessages_WithinEachClass_ShouldBeUnique()
{
- var nestedTypes = typeof(ErrorMessages)
- .GetNestedTypes(BindingFlags.Public | BindingFlags.Static);
+ var seen = new Dictionary<(Type Type, string Message), string>();
- foreach (var type in nestedTypes)
+ foreach (var (type, field, error) in AllErrors())
{
- var seen = new Dictionary();
- var fields = type.GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy)
- .Where(f => f.IsLiteral && !f.IsInitOnly && f.FieldType == typeof(string));
+ var qualifiedName = $"ErrorMessages.{type.Name}.{field.Name}";
+ Assert.False(
+ seen.ContainsKey((type, error.Message)),
+ $"Duplicate error message \"{error.Message}\" found in {qualifiedName} and {seen.GetValueOrDefault((type, error.Message))}.");
+ seen[(type, error.Message)] = qualifiedName;
+ }
+ }
- foreach (var field in fields)
- {
- var value = (string?)field.GetRawConstantValue();
- if (value is null) continue;
-
- var qualifiedName = $"ErrorMessages.{type.Name}.{field.Name}";
- Assert.False(
- seen.ContainsKey(value),
- $"Duplicate error message value \"{value}\" found in {qualifiedName} and {seen.GetValueOrDefault(value)}.");
- seen[value] = qualifiedName;
- }
+ [Fact]
+ public void ErrorCodes_ShouldBeSnakeCase()
+ {
+ foreach (var (type, field, error) in AllErrors())
+ {
+ Assert.True(
+ SnakeCaseRegex().IsMatch(error.Code),
+ $"ErrorMessages.{type.Name}.{field.Name} code \"{error.Code}\" must be snake_case.");
+ }
+ }
+
+ [Fact]
+ public void ErrorCodes_ShouldBeDerivedFromDeclaringClassAndFieldName()
+ {
+ foreach (var (type, field, error) in AllErrors())
+ {
+ var expectedCode = $"{ToSnakeCase(type.Name)}_{ToSnakeCase(field.Name)}";
+ Assert.True(
+ expectedCode == error.Code,
+ $"ErrorMessages.{type.Name}.{field.Name} code must be \"{expectedCode}\" but was \"{error.Code}\".");
+ }
+ }
+
+ [Fact]
+ public void ErrorCodes_ShouldBeGloballyUnique()
+ {
+ var seen = new Dictionary();
+
+ foreach (var (type, field, error) in AllErrors())
+ {
+ var qualifiedName = $"ErrorMessages.{type.Name}.{field.Name}";
+ Assert.False(
+ seen.ContainsKey(error.Code),
+ $"Duplicate error code \"{error.Code}\" found in {qualifiedName} and {seen.GetValueOrDefault(error.Code)}.");
+ seen[error.Code] = qualifiedName;
}
}
}
diff --git a/templates/src/backend/tests/MyProject.Unit.Tests/Shared/ResultGenericTests.cs b/templates/src/backend/tests/MyProject.Unit.Tests/Shared/ResultGenericTests.cs
index 8cffbaa..403efff 100644
--- a/templates/src/backend/tests/MyProject.Unit.Tests/Shared/ResultGenericTests.cs
+++ b/templates/src/backend/tests/MyProject.Unit.Tests/Shared/ResultGenericTests.cs
@@ -4,6 +4,8 @@ namespace MyProject.Unit.Tests.Shared;
public class ResultGenericTests
{
+ private static readonly Error TestError = new("test_error", "error");
+
[Fact]
public void Success_ShouldSetIsSuccessTrue()
{
@@ -33,24 +35,29 @@ public void Success_ShouldHaveNullError()
[Fact]
public void Failure_ShouldSetIsSuccessFalse()
{
- var result = Result.Failure("error");
+ var result = Result.Failure(TestError);
Assert.False(result.IsSuccess);
Assert.True(result.IsFailure);
}
[Fact]
- public void Failure_ShouldPreserveErrorMessage()
+ public void Failure_ShouldPreserveError()
{
- var result = Result.Failure("something broke");
+ var error = new Error("something_broke", "something broke");
+
+ var result = Result.Failure(error);
- Assert.Equal("something broke", result.Error);
+ Assert.NotNull(result.Error);
+ Assert.Same(error, result.Error);
+ Assert.Equal("something_broke", result.Error.Code);
+ Assert.Equal("something broke", result.Error.Message);
}
[Fact]
- public void Failure_WithMessage_ShouldDefaultToValidationErrorType()
+ public void Failure_WithError_ShouldDefaultToValidationErrorType()
{
- var result = Result.Failure("error");
+ var result = Result.Failure(TestError);
Assert.Equal(ErrorType.Validation, result.ErrorType);
}
@@ -59,9 +66,9 @@ public void Failure_WithMessage_ShouldDefaultToValidationErrorType()
[InlineData(ErrorType.Validation)]
[InlineData(ErrorType.Unauthorized)]
[InlineData(ErrorType.NotFound)]
- public void Failure_WithMessageAndErrorType_ShouldPreserveErrorType(ErrorType errorType)
+ public void Failure_WithErrorAndErrorType_ShouldPreserveErrorType(ErrorType errorType)
{
- var result = Result.Failure("error", errorType);
+ var result = Result.Failure(TestError, errorType);
Assert.Equal(errorType, result.ErrorType);
}
@@ -69,7 +76,7 @@ public void Failure_WithMessageAndErrorType_ShouldPreserveErrorType(ErrorType er
[Fact]
public void Value_OnFailure_ShouldThrowInvalidOperationException()
{
- var result = Result.Failure("error");
+ var result = Result.Failure(TestError);
var exception = Assert.Throws(() => result.Value);
Assert.Equal("Cannot access Value on a failed result.", exception.Message);
@@ -96,10 +103,10 @@ public void ResultGeneric_InheritsFromResult()
[Fact]
public void ResultGeneric_Failure_InheritsFromResult()
{
- Result result = Result.Failure("error", ErrorType.NotFound);
+ Result result = Result.Failure(TestError, ErrorType.NotFound);
Assert.True(result.IsFailure);
- Assert.Equal("error", result.Error);
+ Assert.Equal(TestError, result.Error);
Assert.Equal(ErrorType.NotFound, result.ErrorType);
}
}
diff --git a/templates/src/backend/tests/MyProject.Unit.Tests/Shared/ResultTests.cs b/templates/src/backend/tests/MyProject.Unit.Tests/Shared/ResultTests.cs
index cc1ab29..244b8f6 100644
--- a/templates/src/backend/tests/MyProject.Unit.Tests/Shared/ResultTests.cs
+++ b/templates/src/backend/tests/MyProject.Unit.Tests/Shared/ResultTests.cs
@@ -4,6 +4,8 @@ namespace MyProject.Unit.Tests.Shared;
public class ResultTests
{
+ private static readonly Error TestError = new("test_error", "something went wrong");
+
[Fact]
public void Success_ShouldSetIsSuccessTrue()
{
@@ -30,26 +32,29 @@ public void Success_ShouldHaveNullErrorType()
}
[Fact]
- public void Failure_WithMessage_ShouldSetIsSuccessFalse()
+ public void Failure_WithError_ShouldSetIsSuccessFalse()
{
- var result = Result.Failure("something went wrong");
+ var result = Result.Failure(TestError);
Assert.False(result.IsSuccess);
Assert.True(result.IsFailure);
}
[Fact]
- public void Failure_WithMessage_ShouldPreserveError()
+ public void Failure_WithError_ShouldPreserveError()
{
- var result = Result.Failure("something went wrong");
+ var result = Result.Failure(TestError);
- Assert.Equal("something went wrong", result.Error);
+ Assert.NotNull(result.Error);
+ Assert.Same(TestError, result.Error);
+ Assert.Equal("test_error", result.Error.Code);
+ Assert.Equal("something went wrong", result.Error.Message);
}
[Fact]
- public void Failure_WithMessage_ShouldDefaultToValidationErrorType()
+ public void Failure_WithError_ShouldDefaultToValidationErrorType()
{
- var result = Result.Failure("something went wrong");
+ var result = Result.Failure(TestError);
Assert.Equal(ErrorType.Validation, result.ErrorType);
}
@@ -58,19 +63,21 @@ public void Failure_WithMessage_ShouldDefaultToValidationErrorType()
[InlineData(ErrorType.Validation)]
[InlineData(ErrorType.Unauthorized)]
[InlineData(ErrorType.NotFound)]
- public void Failure_WithMessageAndErrorType_ShouldPreserveErrorType(ErrorType errorType)
+ public void Failure_WithErrorAndErrorType_ShouldPreserveErrorType(ErrorType errorType)
{
- var result = Result.Failure("error", errorType);
+ var result = Result.Failure(TestError, errorType);
Assert.Equal(errorType, result.ErrorType);
}
[Fact]
- public void Failure_WithMessageAndErrorType_ShouldPreserveError()
+ public void Failure_WithErrorAndErrorType_ShouldPreserveError()
{
- var result = Result.Failure("not found", ErrorType.NotFound);
+ var error = new Error("not_found", "not found");
+
+ var result = Result.Failure(error, ErrorType.NotFound);
- Assert.Equal("not found", result.Error);
+ Assert.Same(error, result.Error);
Assert.False(result.IsSuccess);
}
}
diff --git a/templates/src/frontend/src/lib/api/error-handling.test.ts b/templates/src/frontend/src/lib/api/error-handling.test.ts
index a37441f..d594d1d 100644
--- a/templates/src/frontend/src/lib/api/error-handling.test.ts
+++ b/templates/src/frontend/src/lib/api/error-handling.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import {
+ getErrorCode,
getErrorMessage,
getRetryAfterSeconds,
isFetchErrorWithCode,
@@ -185,8 +186,68 @@ describe('mapFieldErrors', () => {
});
});
+// ── getErrorCode ────────────────────────────────────────────────────
+
+describe('getErrorCode', () => {
+ it('ProblemDetails with code - returns code', () => {
+ const error = {
+ status: 409,
+ detail: 'Already linked.',
+ code: 'external_auth_already_linked_to_other_user'
+ };
+ expect(getErrorCode(error)).toBe('external_auth_already_linked_to_other_user');
+ });
+
+ it('ProblemDetails without code - returns null', () => {
+ expect(getErrorCode({ status: 404, detail: 'Not found.' })).toBeNull();
+ });
+
+ it('code is empty string - returns null', () => {
+ expect(getErrorCode({ code: '' })).toBeNull();
+ });
+
+ it('code is non-string - returns null', () => {
+ expect(getErrorCode({ code: 42 })).toBeNull();
+ });
+
+ it('null error - returns null', () => {
+ expect(getErrorCode(null)).toBeNull();
+ });
+
+ it('string error - returns null', () => {
+ expect(getErrorCode('oops')).toBeNull();
+ });
+});
+
// ── getErrorMessage ─────────────────────────────────────────────────
+describe('getErrorMessage with messagesByCode', () => {
+ const messages = {
+ auth_login_account_locked: () => 'translated locked',
+ auth_login_invalid_credentials: () => 'translated invalid'
+ };
+
+ it('code matches a message - returns translated message over detail', () => {
+ const error = { detail: 'Account is temporarily locked.', code: 'auth_login_account_locked' };
+ expect(getErrorMessage(error, 'fallback', messages)).toBe('translated locked');
+ });
+
+ it('code without a matching message - falls back to detail', () => {
+ const error = { detail: 'Some other error.', code: 'unknown_code' };
+ expect(getErrorMessage(error, 'fallback', messages)).toBe('Some other error.');
+ });
+
+ it('no code - falls back to detail', () => {
+ const error = { detail: 'Detail only.' };
+ expect(getErrorMessage(error, 'fallback', messages)).toBe('Detail only.');
+ });
+
+ it('code without a matching message and no detail - returns fallback', () => {
+ const error = { code: 'unknown_code' };
+ expect(getErrorMessage(error, 'fallback', messages)).toBe('fallback');
+ });
+});
+
describe('getErrorMessage', () => {
it('error with detail - returns detail', () => {
const error = { detail: 'Invalid credentials', title: 'Unauthorized' };
diff --git a/templates/src/frontend/src/lib/api/error-handling.ts b/templates/src/frontend/src/lib/api/error-handling.ts
index 6f31743..dea6954 100644
--- a/templates/src/frontend/src/lib/api/error-handling.ts
+++ b/templates/src/frontend/src/lib/api/error-handling.ts
@@ -9,20 +9,43 @@
*/
/**
- * Extended ProblemDetails with validation errors.
- * ASP.NET Core returns field-level errors in an `errors` object.
+ * ProblemDetails (RFC 9457) as returned by the backend.
+ *
+ * Every error response carries a stable, machine-readable `code` (snake_case)
+ * alongside the human-readable `detail`. Branch on `code` - never on `detail` text.
*
* @see https://tools.ietf.org/html/rfc9457
*/
-export interface ValidationProblemDetails {
+export interface ProblemDetails {
type?: string | null;
title?: string | null;
status?: number | null;
detail?: string | null;
instance?: string | null;
+ code?: string | null;
+}
+
+/**
+ * Extended ProblemDetails with validation errors.
+ * ASP.NET Core returns field-level errors in an `errors` object.
+ */
+export interface ValidationProblemDetails extends ProblemDetails {
errors?: Record;
}
+/**
+ * Translated messages keyed by backend error `code`.
+ * Values are Paraglide message functions so translation happens lazily in the current locale.
+ *
+ * @example
+ * ```ts
+ * const messages: ErrorMessagesByCode = {
+ * auth_login_account_locked: m.auth_login_accountLocked
+ * };
+ * ```
+ */
+export type ErrorMessagesByCode = Record string>;
+
/**
* Type guard to check if an error response is a ValidationProblemDetails.
*/
@@ -84,21 +107,52 @@ export function mapFieldErrors(
return mapped;
}
+/**
+ * Extracts the machine-readable error `code` from a ProblemDetails API error response.
+ *
+ * @param error - The error object from the API response
+ * @returns The snake_case error code, or `null` when the response carries none
+ */
+export function getErrorCode(error: unknown): string | null {
+ if (
+ typeof error === 'object' &&
+ error !== null &&
+ 'code' in error &&
+ typeof error.code === 'string' &&
+ error.code.length > 0
+ ) {
+ return error.code;
+ }
+ return null;
+}
+
/**
* Extracts a user-friendly error message from a ProblemDetails API error response.
*
* Resolution order:
- * 1. `detail` field → ProblemDetails detail (the specific error message)
- * 2. `title` field → ProblemDetails title (generic status description)
- * 3. Fallback string
+ * 1. `code` field with a matching entry in `messagesByCode` -> translated message
+ * 2. `detail` field -> ProblemDetails detail (the specific English message)
+ * 3. `title` field -> ProblemDetails title (generic status description)
+ * 4. Fallback string
*
- * The backend always returns specific, descriptive English messages in `detail`.
+ * Prefer passing `messagesByCode` for errors the UI wants to translate - the
+ * backend `code` is a stable contract, while `detail` text may change.
*
* @param error - The error object from the API response
* @param fallback - Fallback message if no error message can be extracted
+ * @param messagesByCode - Optional translated messages keyed by backend error code
* @returns A user-friendly error message
*/
-export function getErrorMessage(error: unknown, fallback: string): string {
+export function getErrorMessage(
+ error: unknown,
+ fallback: string,
+ messagesByCode?: ErrorMessagesByCode
+): string {
+ const code = getErrorCode(error);
+ const translated = code === null ? undefined : messagesByCode?.[code];
+ if (translated) {
+ return translated();
+ }
if (typeof error === 'object' && error !== null) {
if ('detail' in error && typeof error.detail === 'string') {
return error.detail;
diff --git a/templates/src/frontend/src/lib/api/v1.d.ts b/templates/src/frontend/src/lib/api/v1.d.ts
index 9fbea59..5b096a7 100644
--- a/templates/src/frontend/src/lib/api/v1.d.ts
+++ b/templates/src/frontend/src/lib/api/v1.d.ts
@@ -4009,6 +4009,8 @@ export interface components {
status?: null | number;
detail?: null | string;
instance?: null | string;
+ /** @description Stable, machine-readable error code (snake_case). Use it to branch on the error or as a translation key instead of matching the human-readable detail. */
+ code?: string;
};
/** @description Detailed recurring job response including execution history. */
RecurringJobDetailResponse: {
diff --git a/templates/src/frontend/src/lib/components/auth/LoginForm.svelte b/templates/src/frontend/src/lib/components/auth/LoginForm.svelte
index ae8ecb5..7b0871e 100644
--- a/templates/src/frontend/src/lib/components/auth/LoginForm.svelte
+++ b/templates/src/frontend/src/lib/components/auth/LoginForm.svelte
@@ -84,14 +84,10 @@
fallback: m.auth_login_error(),
onRateLimited: () => shake.trigger(),
onError() {
- const detail = getErrorMessage(apiError, '');
- const isLocked = detail.includes('temporarily locked');
- const errorMessage =
- response.status === 401
- ? isLocked
- ? m.auth_login_accountLocked()
- : getErrorMessage(apiError, m.auth_login_invalidCredentials())
- : getErrorMessage(apiError, m.auth_login_error());
+ const errorMessage = getErrorMessage(apiError, m.auth_login_error(), {
+ auth_login_invalid_credentials: m.auth_login_invalidCredentials,
+ auth_login_account_locked: m.auth_login_accountLocked
+ });
toast.error(m.auth_login_failed(), { description: errorMessage });
shake.trigger();
}
diff --git a/templates/src/frontend/src/messages/cs/oauth.json b/templates/src/frontend/src/messages/cs/oauth.json
index 2818259..b5c2311 100644
--- a/templates/src/frontend/src/messages/cs/oauth.json
+++ b/templates/src/frontend/src/messages/cs/oauth.json
@@ -17,6 +17,8 @@
"oauth_callback_alreadyLinked": "Tento externí účet je již propojen s jiným uživatelem.",
"oauth_callback_emailNotVerified": "Před propojením externího účtu je nutné ověřit e-mailovou adresu. Nejprve prosím ověřte svůj e-mail.",
"oauth_callback_stateExpired": "Přihlašovací relace vypršela. Zkuste to prosím znovu.",
+ "oauth_callback_invalidState": "Přihlašovací požadavek je neplatný nebo již byl použit. Zkuste to prosím znovu.",
+ "oauth_callback_providerError": "Poskytovatel přihlášení vrátil chybu. Zkuste to prosím později.",
"oauth_callback_accountLocked": "Váš účet je dočasně uzamčen. Zkuste to prosím později.",
"settings_oauth_title": "Propojené účty",
diff --git a/templates/src/frontend/src/messages/en/oauth.json b/templates/src/frontend/src/messages/en/oauth.json
index 82b92eb..cbcb84f 100644
--- a/templates/src/frontend/src/messages/en/oauth.json
+++ b/templates/src/frontend/src/messages/en/oauth.json
@@ -17,6 +17,8 @@
"oauth_callback_alreadyLinked": "This external account is already linked to another user.",
"oauth_callback_emailNotVerified": "Your email must be verified before linking an external account. Please verify your email first.",
"oauth_callback_stateExpired": "The sign-in session has expired. Please try again.",
+ "oauth_callback_invalidState": "The sign-in request is invalid or was already used. Please try again.",
+ "oauth_callback_providerError": "The sign-in provider returned an error. Please try again later.",
"oauth_callback_accountLocked": "Your account is temporarily locked. Please try again later.",
"settings_oauth_title": "Connected Accounts",
diff --git a/templates/src/frontend/src/routes/(public)/oauth/callback/+page.server.ts b/templates/src/frontend/src/routes/(public)/oauth/callback/+page.server.ts
index 74ca7ac..c617c55 100644
--- a/templates/src/frontend/src/routes/(public)/oauth/callback/+page.server.ts
+++ b/templates/src/frontend/src/routes/(public)/oauth/callback/+page.server.ts
@@ -1,5 +1,6 @@
// @feature oauth
import { isRedirect, redirect } from '@sveltejs/kit';
+import { getErrorCode } from '$lib/api';
import { routes } from '$lib/config';
import type { PageServerLoad } from './$types';
@@ -25,9 +26,8 @@ export const load: PageServerLoad = async ({ url, fetch }) => {
});
if (!response.ok) {
- const body = await response.json().catch(() => null);
- const detail = body?.detail ?? 'Unknown error';
- return { error: detail };
+ const body: unknown = await response.json().catch(() => null);
+ return { error: getErrorCode(body) ?? 'unknown_error' };
}
const data = await response.json();
diff --git a/templates/src/frontend/src/routes/(public)/oauth/callback/+page.svelte b/templates/src/frontend/src/routes/(public)/oauth/callback/+page.svelte
index ec98e7f..108c23d 100644
--- a/templates/src/frontend/src/routes/(public)/oauth/callback/+page.svelte
+++ b/templates/src/frontend/src/routes/(public)/oauth/callback/+page.svelte
@@ -7,30 +7,28 @@
import { Loader2, CircleAlert } from '@lucide/svelte';
import { IconCircle } from '$lib/components/common';
import { AuthShell } from '$lib/components/auth';
+ import type { ErrorMessagesByCode } from '$lib/api';
let { data } = $props();
/**
- * Maps backend ProblemDetails `detail` strings to translated messages.
- * Keys are the exact English strings from ErrorMessages.cs.
- * Unmapped errors fall back to the generic description.
- *
- * TODO: Remove this map once the backend returns error codes instead of
- * English strings, and use those codes as i18n keys directly.
+ * Translated messages keyed by error code. Backend codes come from the `code`
+ * extension of the ProblemDetails response (see ErrorMessages.cs); the rest are
+ * produced locally by the page load. Unmapped codes fall back to the generic description.
*/
- const ERROR_MAP: Record string> = {
- provider_denied: () => m.oauth_callback_providerDenied(),
- 'This external account is already linked to another user.': () =>
- m.oauth_callback_alreadyLinked(),
- 'Your email address must be verified before linking an external account. Please verify your email first.':
- () => m.oauth_callback_emailNotVerified(),
- 'OAuth state token has expired. Please try again.': () => m.oauth_callback_stateExpired(),
- 'Account is temporarily locked. Please try again later or contact an administrator.': () =>
- m.oauth_callback_accountLocked()
+ const ERROR_MESSAGES: ErrorMessagesByCode = {
+ provider_denied: m.oauth_callback_providerDenied,
+ external_auth_already_linked_to_other_user: m.oauth_callback_alreadyLinked,
+ external_auth_email_not_verified: m.oauth_callback_emailNotVerified,
+ external_auth_state_expired: m.oauth_callback_stateExpired,
+ external_auth_invalid_state: m.oauth_callback_invalidState,
+ external_auth_code_exchange_failed: m.oauth_callback_providerError,
+ external_auth_provider_error: m.oauth_callback_providerError,
+ auth_login_account_locked: m.oauth_callback_accountLocked
};
const errorMessage = $derived(
- (data.error && ERROR_MAP[data.error]?.()) ?? m.oauth_callback_errorDescription()
+ (data.error && ERROR_MESSAGES[data.error]?.()) ?? m.oauth_callback_errorDescription()
);
diff --git a/templates/src/frontend/src/routes/(public)/oauth/callback/page.server.test.ts b/templates/src/frontend/src/routes/(public)/oauth/callback/page.server.test.ts
index 3b0f873..56bd655 100644
--- a/templates/src/frontend/src/routes/(public)/oauth/callback/page.server.test.ts
+++ b/templates/src/frontend/src/routes/(public)/oauth/callback/page.server.test.ts
@@ -112,27 +112,30 @@ describe('OAuth callback page server load', () => {
// ── API error responses ────────────────────────────────────
- it('API returns error with detail - returns detail message', async () => {
+ it('API returns ProblemDetails with code - returns the code', async () => {
const result = await load(
mockLoadEvent({
searchParams: { code: 'auth-code', state: 'state-token' },
fetchResponse: {
ok: false,
- json: { detail: 'Invalid or missing OAuth state token.' }
+ json: {
+ detail: 'Invalid or missing OAuth state token.',
+ code: 'external_auth_invalid_state'
+ }
}
})
);
- expect(result).toEqual({ error: 'Invalid or missing OAuth state token.' });
+ expect(result).toEqual({ error: 'external_auth_invalid_state' });
});
- it('API returns error without detail - returns Unknown error', async () => {
+ it('API returns error without code - returns unknown_error', async () => {
const result = await load(
mockLoadEvent({
searchParams: { code: 'auth-code', state: 'state-token' },
- fetchResponse: { ok: false, json: {} }
+ fetchResponse: { ok: false, json: { detail: 'Some message.' } }
})
);
- expect(result).toEqual({ error: 'Unknown error' });
+ expect(result).toEqual({ error: 'unknown_error' });
});
// ── Network error ──────────────────────────────────────────
diff --git a/templates/src/frontend/src/routes/api/[...path]/+server.ts b/templates/src/frontend/src/routes/api/[...path]/+server.ts
index 36cc3d3..77a2a29 100644
--- a/templates/src/frontend/src/routes/api/[...path]/+server.ts
+++ b/templates/src/frontend/src/routes/api/[...path]/+server.ts
@@ -163,12 +163,17 @@ export const fallback: RequestHandler = async ({
const queryString = targetParams.toString();
const targetUrl = `${SERVER_CONFIG.API_URL}/api/${params.path}${queryString ? `?${queryString}` : ''}`;
+ // Buffer the body instead of forwarding the incoming ReadableStream. undici
+ // (Node 24.14 - 24.15) turns any 401 backend response into a network error
+ // when the request body is a stream without a byte source ("expected
+ // non-null body source"), which the proxy would surface as a 502 instead of
+ // the real 401 (nodejs/undici#5018).
+ const body = request.body ? await request.arrayBuffer() : null;
+
const newRequest = new Request(targetUrl, {
method: request.method,
headers: filterRequestHeaders(request.headers, getClientAddress()),
- body: request.body,
- // @ts-expect-error - duplex is needed for streaming bodies in some node versions/fetch implementations
- duplex: 'half'
+ body
});
try {
diff --git a/templates/src/frontend/src/routes/api/proxy.test.ts b/templates/src/frontend/src/routes/api/proxy.test.ts
index cc48ef8..0fe1372 100644
--- a/templates/src/frontend/src/routes/api/proxy.test.ts
+++ b/templates/src/frontend/src/routes/api/proxy.test.ts
@@ -67,9 +67,10 @@ function mockProxyEvent(
requestHeaders.set('origin', origin);
}
- const requestInit: RequestInit = { method, headers: requestHeaders };
+ const requestInit: RequestInit & { duplex?: 'half' } = { method, headers: requestHeaders };
if (body && method !== 'GET' && method !== 'HEAD') {
requestInit.body = body;
+ requestInit.duplex = 'half';
}
const request = new Request(url.toString(), requestInit);
@@ -555,6 +556,45 @@ describe('API proxy - URL construction and cookie auth paths', () => {
});
});
+describe('API proxy - request body forwarding', () => {
+ /** Wraps a payload in a ReadableStream, matching what SvelteKit hands the proxy. */
+ function streamBody(payload: string): ReadableStream {
+ return new ReadableStream({
+ start(controller) {
+ controller.enqueue(new TextEncoder().encode(payload));
+ controller.close();
+ }
+ });
+ }
+
+ it('buffers a streamed request body before proxying', async () => {
+ // Regression for nodejs/undici#5018 (see the proxy handler for details).
+ const payload = '{"username":"user@example.com","password":"wrong"}';
+ const event = mockProxyEvent({
+ method: 'POST',
+ path: 'auth/login',
+ origin: 'http://localhost:5173',
+ headers: { 'content-type': 'application/json' },
+ body: streamBody(payload),
+ fetchResponse: new Response('{"title":"Unauthorized"}', { status: 401 })
+ });
+
+ const response = await fallback(event);
+
+ expect(response.status).toBe(401);
+ expect(event.request.bodyUsed).toBe(true);
+ expect(await getProxiedRequest(event).text()).toBe(payload);
+ });
+
+ it('does not attach a body to bodyless requests', async () => {
+ const event = mockProxyEvent({ method: 'GET' });
+
+ await fallback(event);
+
+ expect(getProxiedRequest(event).body).toBeNull();
+ });
+});
+
describe('API proxy - error handling', () => {
beforeEach(() => {
vi.spyOn(console, 'error').mockImplementation(() => {});