Add i18n support with English and Spanish translations
- Created i18n package with translator and middleware - Added translation files for English (en.json) and Spanish (es.json) - Updated all HTTP handlers to use i18n for error/success messages - Added comprehensive test coverage for i18n (87%) - Updated CI workflow to use Go 1.24 - All tests passing with 50.8% total coverage
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
package i18n
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
//go:embed locales/en.json
|
||||
var enTranslations []byte
|
||||
|
||||
//go:embed locales/es.json
|
||||
var esTranslations []byte
|
||||
|
||||
type Translations struct {
|
||||
Errors map[string]string `json:"errors"`
|
||||
Success map[string]string `json:"success"`
|
||||
Validation map[string]string `json:"validation"`
|
||||
Emails map[string]string `json:"emails"`
|
||||
}
|
||||
|
||||
type Translator struct {
|
||||
translations map[language.Tag]Translations
|
||||
matcher language.Matcher
|
||||
}
|
||||
|
||||
func NewTranslator() (*Translator, error) {
|
||||
var enTrans, esTrans Translations
|
||||
|
||||
if err := json.Unmarshal(enTranslations, &enTrans); err != nil {
|
||||
return nil, fmt.Errorf("failed to load English translations: %w", err)
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(esTranslations, &esTrans); err != nil {
|
||||
return nil, fmt.Errorf("failed to load Spanish translations: %w", err)
|
||||
}
|
||||
|
||||
translations := map[language.Tag]Translations{
|
||||
language.English: enTrans,
|
||||
language.Spanish: esTrans,
|
||||
}
|
||||
|
||||
matcher := language.NewMatcher([]language.Tag{
|
||||
language.English,
|
||||
language.Spanish,
|
||||
})
|
||||
|
||||
return &Translator{
|
||||
translations: translations,
|
||||
matcher: matcher,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (t *Translator) GetLanguage(acceptLanguage string) language.Tag {
|
||||
if acceptLanguage == "" {
|
||||
return language.English
|
||||
}
|
||||
|
||||
tags, _, err := language.ParseAcceptLanguage(acceptLanguage)
|
||||
if err != nil || len(tags) == 0 {
|
||||
return language.English
|
||||
}
|
||||
|
||||
_, index, _ := t.matcher.Match(tags...)
|
||||
supportedTags := []language.Tag{language.English, language.Spanish}
|
||||
if index < len(supportedTags) {
|
||||
return supportedTags[index]
|
||||
}
|
||||
|
||||
return language.English
|
||||
}
|
||||
|
||||
func (t *Translator) Translate(lang language.Tag, category, key string) string {
|
||||
trans, ok := t.translations[lang]
|
||||
if !ok {
|
||||
trans = t.translations[language.English]
|
||||
}
|
||||
|
||||
var categoryMap map[string]string
|
||||
switch category {
|
||||
case "errors":
|
||||
categoryMap = trans.Errors
|
||||
case "success":
|
||||
categoryMap = trans.Success
|
||||
case "validation":
|
||||
categoryMap = trans.Validation
|
||||
case "emails":
|
||||
categoryMap = trans.Emails
|
||||
default:
|
||||
return key
|
||||
}
|
||||
|
||||
if value, ok := categoryMap[key]; ok {
|
||||
return value
|
||||
}
|
||||
|
||||
return key
|
||||
}
|
||||
|
||||
func (t *Translator) Error(lang language.Tag, key string) string {
|
||||
return t.Translate(lang, "errors", key)
|
||||
}
|
||||
|
||||
func (t *Translator) Success(lang language.Tag, key string) string {
|
||||
return t.Translate(lang, "success", key)
|
||||
}
|
||||
|
||||
func (t *Translator) Validation(lang language.Tag, key string) string {
|
||||
return t.Translate(lang, "validation", key)
|
||||
}
|
||||
|
||||
func (t *Translator) Email(lang language.Tag, key string) string {
|
||||
return t.Translate(lang, "emails", key)
|
||||
}
|
||||
|
||||
func (t *Translator) TranslateValidationError(lang language.Tag, field, validationKey string) string {
|
||||
message := t.Validation(lang, validationKey)
|
||||
return strings.ReplaceAll(message, field, field)
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
package i18n
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
func TestNewTranslator(t *testing.T) {
|
||||
translator, err := NewTranslator()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create translator: %v", err)
|
||||
}
|
||||
|
||||
if translator == nil {
|
||||
t.Fatal("Expected translator to be non-nil")
|
||||
}
|
||||
|
||||
if translator.translations == nil {
|
||||
t.Fatal("Expected translations map to be initialized")
|
||||
}
|
||||
|
||||
if len(translator.translations) != 2 {
|
||||
t.Errorf("Expected 2 languages, got %d", len(translator.translations))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLanguage(t *testing.T) {
|
||||
translator, _ := NewTranslator()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
acceptLanguage string
|
||||
expected language.Tag
|
||||
}{
|
||||
{
|
||||
name: "English",
|
||||
acceptLanguage: "en-US",
|
||||
expected: language.English,
|
||||
},
|
||||
{
|
||||
name: "Spanish",
|
||||
acceptLanguage: "es-ES",
|
||||
expected: language.Spanish,
|
||||
},
|
||||
{
|
||||
name: "Empty defaults to English",
|
||||
acceptLanguage: "",
|
||||
expected: language.English,
|
||||
},
|
||||
{
|
||||
name: "Unknown language defaults to English",
|
||||
acceptLanguage: "fr-FR",
|
||||
expected: language.English,
|
||||
},
|
||||
{
|
||||
name: "Spanish with quality",
|
||||
acceptLanguage: "es-ES,es;q=0.9",
|
||||
expected: language.Spanish,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := translator.GetLanguage(tt.acceptLanguage)
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected %v, got %v", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestError(t *testing.T) {
|
||||
translator, _ := NewTranslator()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
lang language.Tag
|
||||
key string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "English error message",
|
||||
lang: language.English,
|
||||
key: "invalid_request_body",
|
||||
expected: "Invalid request body",
|
||||
},
|
||||
{
|
||||
name: "Spanish error message",
|
||||
lang: language.Spanish,
|
||||
key: "invalid_request_body",
|
||||
expected: "Cuerpo de solicitud inválido",
|
||||
},
|
||||
{
|
||||
name: "Missing key returns key",
|
||||
lang: language.English,
|
||||
key: "non_existent_key",
|
||||
expected: "non_existent_key",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := translator.Error(tt.lang, tt.key)
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected '%s', got '%s'", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuccess(t *testing.T) {
|
||||
translator, _ := NewTranslator()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
lang language.Tag
|
||||
key string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "English success message",
|
||||
lang: language.English,
|
||||
key: "logged_out",
|
||||
expected: "Successfully logged out",
|
||||
},
|
||||
{
|
||||
name: "Spanish success message",
|
||||
lang: language.Spanish,
|
||||
key: "logged_out",
|
||||
expected: "Sesión cerrada exitosamente",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := translator.Success(tt.lang, tt.key)
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected '%s', got '%s'", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidation(t *testing.T) {
|
||||
translator, _ := NewTranslator()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
lang language.Tag
|
||||
key string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "English validation message",
|
||||
lang: language.English,
|
||||
key: "email_required",
|
||||
expected: "email is required",
|
||||
},
|
||||
{
|
||||
name: "Spanish validation message",
|
||||
lang: language.Spanish,
|
||||
key: "email_required",
|
||||
expected: "el email es requerido",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := translator.Validation(tt.lang, tt.key)
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected '%s', got '%s'", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmail(t *testing.T) {
|
||||
translator, _ := NewTranslator()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
lang language.Tag
|
||||
key string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "English email message",
|
||||
lang: language.English,
|
||||
key: "welcome_subject",
|
||||
expected: "Welcome to Apocapoc!",
|
||||
},
|
||||
{
|
||||
name: "Spanish email message",
|
||||
lang: language.Spanish,
|
||||
key: "welcome_subject",
|
||||
expected: "¡Bienvenido a Apocapoc!",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := translator.Email(tt.lang, tt.key)
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected '%s', got '%s'", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslate(t *testing.T) {
|
||||
translator, _ := NewTranslator()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
lang language.Tag
|
||||
category string
|
||||
key string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "Valid category and key",
|
||||
lang: language.English,
|
||||
category: "errors",
|
||||
key: "user_not_found",
|
||||
expected: "User not found",
|
||||
},
|
||||
{
|
||||
name: "Invalid category returns key",
|
||||
lang: language.English,
|
||||
category: "invalid_category",
|
||||
key: "some_key",
|
||||
expected: "some_key",
|
||||
},
|
||||
{
|
||||
name: "Unsupported language fallback to English",
|
||||
lang: language.French,
|
||||
category: "errors",
|
||||
key: "user_not_found",
|
||||
expected: "User not found",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := translator.Translate(tt.lang, tt.category, tt.key)
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected '%s', got '%s'", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
{
|
||||
"errors": {
|
||||
"invalid_request_body": "Invalid request body",
|
||||
"email_already_registered": "Email already registered",
|
||||
"registration_closed": "Registration is currently closed",
|
||||
"failed_register_user": "Failed to register user",
|
||||
"invalid_credentials": "Invalid email or password",
|
||||
"email_not_verified": "Please verify your email before logging in",
|
||||
"failed_login": "Failed to login",
|
||||
"failed_generate_token": "Failed to generate token",
|
||||
"failed_create_refresh_token": "Failed to create refresh token",
|
||||
"failed_save_refresh_token": "Failed to save refresh token",
|
||||
"invalid_expired_refresh_token": "Invalid or expired refresh token",
|
||||
"failed_refresh_token": "Failed to refresh token",
|
||||
"refresh_token_not_found": "Refresh token not found",
|
||||
"invalid_refresh_token": "Invalid refresh token",
|
||||
"user_not_authenticated": "User not authenticated",
|
||||
"failed_get_user": "Failed to get user",
|
||||
"failed_get_habits": "Failed to get habits",
|
||||
"failed_create_habit": "Failed to create habit",
|
||||
"habit_not_found": "Habit not found",
|
||||
"access_denied": "Access denied",
|
||||
"failed_get_habit": "Failed to get habit",
|
||||
"invalid_input": "Invalid input",
|
||||
"failed_update_habit": "Failed to update habit",
|
||||
"failed_archive_habit": "Failed to archive habit",
|
||||
"failed_get_habit_entries": "Failed to get habit entries",
|
||||
"invalid_date_format": "Invalid date format (use YYYY-MM-DD)",
|
||||
"invalid_page_parameter": "Invalid 'page' parameter",
|
||||
"invalid_limit_parameter": "Invalid 'limit' parameter (must be 1-100)",
|
||||
"pagination_required": "Pagination required: provide 'limit' parameter or use date range ≤ 1 year",
|
||||
"invalid_from_date_format": "Invalid 'from' date format (use YYYY-MM-DD)",
|
||||
"invalid_to_date_format": "Invalid 'to' date format (use YYYY-MM-DD)",
|
||||
"habit_already_marked": "Habit already marked for this date",
|
||||
"failed_mark_habit": "Failed to mark habit",
|
||||
"habit_entry_not_found": "Habit entry not found",
|
||||
"failed_unmark_habit": "Failed to unmark habit",
|
||||
"invalid_expired_verification_token": "Invalid or expired verification token",
|
||||
"email_already_verified": "Email already verified",
|
||||
"failed_verify_email": "Failed to verify email",
|
||||
"invalid_email": "Invalid email",
|
||||
"user_not_found": "User not found",
|
||||
"failed_send_verification_email": "Failed to send verification email",
|
||||
"email_not_verified_reset": "Please verify your email before resetting password",
|
||||
"failed_send_reset_email": "Failed to send reset email",
|
||||
"invalid_token_or_password": "Invalid or expired token, or password requirements not met",
|
||||
"failed_reset_password": "Failed to reset password",
|
||||
"failed_delete_user": "Failed to delete user",
|
||||
"failed_get_stats": "Failed to get statistics"
|
||||
},
|
||||
"success": {
|
||||
"registration_with_verification": "Registration successful. Please check your email to verify your account.",
|
||||
"registration_without_verification": "Registration successful. You can now login.",
|
||||
"logged_out": "Successfully logged out",
|
||||
"email_verified": "Email verified successfully",
|
||||
"verification_email_sent": "Verification email sent successfully",
|
||||
"password_reset_email_sent": "Password reset email sent successfully",
|
||||
"password_reset": "Password reset successfully",
|
||||
"user_deleted": "User and all associated data deleted successfully"
|
||||
},
|
||||
"validation": {
|
||||
"email_required": "email is required",
|
||||
"email_invalid_format": "email must be a valid email address",
|
||||
"email_too_long": "email must not exceed 254 characters",
|
||||
"password_required": "password is required",
|
||||
"password_min_length": "password must be at least 8 characters long",
|
||||
"password_max_length": "password must not exceed 128 characters",
|
||||
"password_uppercase": "password must contain at least one uppercase letter",
|
||||
"password_lowercase": "password must contain at least one lowercase letter",
|
||||
"password_digit": "password must contain at least one digit",
|
||||
"password_special_char": "password must contain at least one special character (!@#$%^&*)",
|
||||
"timezone_required": "timezone is required",
|
||||
"timezone_invalid": "timezone is not a valid IANA timezone",
|
||||
"name_required": "name is required",
|
||||
"name_too_long": "name must not exceed 255 characters",
|
||||
"type_invalid": "type must be one of: BOOLEAN, COUNTER, VALUE",
|
||||
"frequency_invalid": "frequency must be one of: DAILY, WEEKLY, MONTHLY",
|
||||
"specific_days_required": "specific_days is required for WEEKLY frequency",
|
||||
"specific_days_invalid": "specific_days must contain values between 0-6 (0=Sunday, 6=Saturday)",
|
||||
"specific_dates_required": "specific_dates is required for MONTHLY frequency",
|
||||
"specific_dates_invalid": "specific_dates must contain values between 1-31",
|
||||
"target_value_required": "target_value is required for VALUE type",
|
||||
"target_value_positive": "target_value must be positive"
|
||||
},
|
||||
"emails": {
|
||||
"verify_email_subject": "Verify your email address",
|
||||
"verify_email_title": "Welcome! Please verify your email",
|
||||
"verify_email_body": "Thank you for registering. Please click the link below to verify your email address:",
|
||||
"verify_email_link": "Verify Email",
|
||||
"verify_email_expiry": "This link will expire in 24 hours.",
|
||||
"verify_email_ignore": "If you didn't create an account, you can safely ignore this email.",
|
||||
"resend_verification_subject": "Verify your email address",
|
||||
"resend_verification_title": "Verify your email address",
|
||||
"resend_verification_body": "Please click the link below to verify your email address:",
|
||||
"resend_verification_link": "Verify Email",
|
||||
"resend_verification_expiry": "This link will expire in 24 hours.",
|
||||
"resend_verification_ignore": "If you didn't request this, you can safely ignore this email.",
|
||||
"password_reset_subject": "Password Reset Request",
|
||||
"password_reset_title": "Password Reset Request",
|
||||
"password_reset_body": "You have requested to reset your password. Please click the link below:",
|
||||
"password_reset_link": "Reset Password",
|
||||
"password_reset_expiry": "This link will expire in 1 hour.",
|
||||
"password_reset_ignore": "If you didn't request this, you can safely ignore this email.",
|
||||
"welcome_subject": "Welcome to Apocapoc!",
|
||||
"welcome_title": "Welcome to Apocapoc!",
|
||||
"welcome_body": "Your email has been verified successfully. You can now start using all features of Apocapoc.",
|
||||
"welcome_enjoy": "Enjoy building better habits!"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
{
|
||||
"errors": {
|
||||
"invalid_request_body": "Cuerpo de solicitud inválido",
|
||||
"email_already_registered": "El correo electrónico ya está registrado",
|
||||
"registration_closed": "El registro está actualmente cerrado",
|
||||
"failed_register_user": "Error al registrar usuario",
|
||||
"invalid_credentials": "Correo electrónico o contraseña inválidos",
|
||||
"email_not_verified": "Por favor verifica tu correo electrónico antes de iniciar sesión",
|
||||
"failed_login": "Error al iniciar sesión",
|
||||
"failed_generate_token": "Error al generar token",
|
||||
"failed_create_refresh_token": "Error al crear token de actualización",
|
||||
"failed_save_refresh_token": "Error al guardar token de actualización",
|
||||
"invalid_expired_refresh_token": "Token de actualización inválido o expirado",
|
||||
"failed_refresh_token": "Error al actualizar token",
|
||||
"refresh_token_not_found": "Token de actualización no encontrado",
|
||||
"invalid_refresh_token": "Token de actualización inválido",
|
||||
"user_not_authenticated": "Usuario no autenticado",
|
||||
"failed_get_user": "Error al obtener usuario",
|
||||
"failed_get_habits": "Error al obtener hábitos",
|
||||
"failed_create_habit": "Error al crear hábito",
|
||||
"habit_not_found": "Hábito no encontrado",
|
||||
"access_denied": "Acceso denegado",
|
||||
"failed_get_habit": "Error al obtener hábito",
|
||||
"invalid_input": "Entrada inválida",
|
||||
"failed_update_habit": "Error al actualizar hábito",
|
||||
"failed_archive_habit": "Error al archivar hábito",
|
||||
"failed_get_habit_entries": "Error al obtener entradas de hábito",
|
||||
"invalid_date_format": "Formato de fecha inválido (usa AAAA-MM-DD)",
|
||||
"invalid_page_parameter": "Parámetro 'page' inválido",
|
||||
"invalid_limit_parameter": "Parámetro 'limit' inválido (debe ser 1-100)",
|
||||
"pagination_required": "Se requiere paginación: proporciona el parámetro 'limit' o usa un rango de fechas ≤ 1 año",
|
||||
"invalid_from_date_format": "Formato de fecha 'from' inválido (usa AAAA-MM-DD)",
|
||||
"invalid_to_date_format": "Formato de fecha 'to' inválido (usa AAAA-MM-DD)",
|
||||
"habit_already_marked": "El hábito ya está marcado para esta fecha",
|
||||
"failed_mark_habit": "Error al marcar hábito",
|
||||
"habit_entry_not_found": "Entrada de hábito no encontrada",
|
||||
"failed_unmark_habit": "Error al desmarcar hábito",
|
||||
"invalid_expired_verification_token": "Token de verificación inválido o expirado",
|
||||
"email_already_verified": "El correo electrónico ya está verificado",
|
||||
"failed_verify_email": "Error al verificar correo electrónico",
|
||||
"invalid_email": "Correo electrónico inválido",
|
||||
"user_not_found": "Usuario no encontrado",
|
||||
"failed_send_verification_email": "Error al enviar correo de verificación",
|
||||
"email_not_verified_reset": "Por favor verifica tu correo electrónico antes de restablecer la contraseña",
|
||||
"failed_send_reset_email": "Error al enviar correo de restablecimiento",
|
||||
"invalid_token_or_password": "Token inválido o expirado, o no se cumplen los requisitos de contraseña",
|
||||
"failed_reset_password": "Error al restablecer contraseña",
|
||||
"failed_delete_user": "Error al eliminar usuario",
|
||||
"failed_get_stats": "Error al obtener estadísticas"
|
||||
},
|
||||
"success": {
|
||||
"registration_with_verification": "Registro exitoso. Por favor revisa tu correo electrónico para verificar tu cuenta.",
|
||||
"registration_without_verification": "Registro exitoso. Ya puedes iniciar sesión.",
|
||||
"logged_out": "Sesión cerrada exitosamente",
|
||||
"email_verified": "Correo electrónico verificado exitosamente",
|
||||
"verification_email_sent": "Correo de verificación enviado exitosamente",
|
||||
"password_reset_email_sent": "Correo de restablecimiento de contraseña enviado exitosamente",
|
||||
"password_reset": "Contraseña restablecida exitosamente",
|
||||
"user_deleted": "Usuario y todos los datos asociados eliminados exitosamente"
|
||||
},
|
||||
"validation": {
|
||||
"email_required": "el email es requerido",
|
||||
"email_invalid_format": "el email debe ser una dirección de correo válida",
|
||||
"email_too_long": "el email no debe exceder 254 caracteres",
|
||||
"password_required": "la password es requerida",
|
||||
"password_min_length": "la password debe tener al menos 8 caracteres",
|
||||
"password_max_length": "la password no debe exceder 128 caracteres",
|
||||
"password_uppercase": "la password debe contener al menos una letra mayúscula",
|
||||
"password_lowercase": "la password debe contener al menos una letra minúscula",
|
||||
"password_digit": "la password debe contener al menos un dígito",
|
||||
"password_special_char": "la password debe contener al menos un carácter especial (!@#$%^&*)",
|
||||
"timezone_required": "la timezone es requerida",
|
||||
"timezone_invalid": "la timezone no es una zona horaria IANA válida",
|
||||
"name_required": "el name es requerido",
|
||||
"name_too_long": "el name no debe exceder 255 caracteres",
|
||||
"type_invalid": "el type debe ser uno de: BOOLEAN, COUNTER, VALUE",
|
||||
"frequency_invalid": "la frequency debe ser una de: DAILY, WEEKLY, MONTHLY",
|
||||
"specific_days_required": "specific_days es requerido para frecuencia WEEKLY",
|
||||
"specific_days_invalid": "specific_days debe contener valores entre 0-6 (0=Domingo, 6=Sábado)",
|
||||
"specific_dates_required": "specific_dates es requerido para frecuencia MONTHLY",
|
||||
"specific_dates_invalid": "specific_dates debe contener valores entre 1-31",
|
||||
"target_value_required": "target_value es requerido para tipo VALUE",
|
||||
"target_value_positive": "target_value debe ser positivo"
|
||||
},
|
||||
"emails": {
|
||||
"verify_email_subject": "Verifica tu dirección de correo electrónico",
|
||||
"verify_email_title": "¡Bienvenido! Por favor verifica tu correo electrónico",
|
||||
"verify_email_body": "Gracias por registrarte. Por favor haz clic en el enlace a continuación para verificar tu dirección de correo electrónico:",
|
||||
"verify_email_link": "Verificar correo electrónico",
|
||||
"verify_email_expiry": "Este enlace expirará en 24 horas.",
|
||||
"verify_email_ignore": "Si no creaste una cuenta, puedes ignorar este correo de forma segura.",
|
||||
"resend_verification_subject": "Verifica tu dirección de correo electrónico",
|
||||
"resend_verification_title": "Verifica tu dirección de correo electrónico",
|
||||
"resend_verification_body": "Por favor haz clic en el enlace a continuación para verificar tu dirección de correo electrónico:",
|
||||
"resend_verification_link": "Verificar correo electrónico",
|
||||
"resend_verification_expiry": "Este enlace expirará en 24 horas.",
|
||||
"resend_verification_ignore": "Si no solicitaste esto, puedes ignorar este correo de forma segura.",
|
||||
"password_reset_subject": "Solicitud de restablecimiento de contraseña",
|
||||
"password_reset_title": "Solicitud de restablecimiento de contraseña",
|
||||
"password_reset_body": "Has solicitado restablecer tu contraseña. Por favor haz clic en el enlace a continuación:",
|
||||
"password_reset_link": "Restablecer contraseña",
|
||||
"password_reset_expiry": "Este enlace expirará en 1 hora.",
|
||||
"password_reset_ignore": "Si no solicitaste esto, puedes ignorar este correo de forma segura.",
|
||||
"welcome_subject": "¡Bienvenido a Apocapoc!",
|
||||
"welcome_title": "¡Bienvenido a Apocapoc!",
|
||||
"welcome_body": "Tu correo electrónico ha sido verificado exitosamente. Ya puedes comenzar a usar todas las funcionalidades de Apocapoc.",
|
||||
"welcome_enjoy": "¡Disfruta construyendo mejores hábitos!"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package i18n
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const languageKey contextKey = "language"
|
||||
|
||||
func LanguageMiddleware(translator *Translator) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
acceptLanguage := r.Header.Get("Accept-Language")
|
||||
lang := translator.GetLanguage(acceptLanguage)
|
||||
ctx := context.WithValue(r.Context(), languageKey, lang)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func GetLanguageFromContext(ctx context.Context) language.Tag {
|
||||
if lang, ok := ctx.Value(languageKey).(language.Tag); ok {
|
||||
return lang
|
||||
}
|
||||
return language.English
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package i18n
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
func TestLanguageMiddleware(t *testing.T) {
|
||||
translator, _ := NewTranslator()
|
||||
middleware := LanguageMiddleware(translator)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
acceptLanguage string
|
||||
expectedLang language.Tag
|
||||
}{
|
||||
{
|
||||
name: "English header",
|
||||
acceptLanguage: "en-US",
|
||||
expectedLang: language.English,
|
||||
},
|
||||
{
|
||||
name: "Spanish header",
|
||||
acceptLanguage: "es-ES",
|
||||
expectedLang: language.Spanish,
|
||||
},
|
||||
{
|
||||
name: "No header defaults to English",
|
||||
acceptLanguage: "",
|
||||
expectedLang: language.English,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var capturedLang language.Tag
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
capturedLang = GetLanguageFromContext(r.Context())
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
wrappedHandler := middleware(handler)
|
||||
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
if tt.acceptLanguage != "" {
|
||||
req.Header.Set("Accept-Language", tt.acceptLanguage)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
wrappedHandler.ServeHTTP(w, req)
|
||||
|
||||
if capturedLang != tt.expectedLang {
|
||||
t.Errorf("Expected language %v, got %v", tt.expectedLang, capturedLang)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLanguageFromContext(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ctx context.Context
|
||||
expected language.Tag
|
||||
}{
|
||||
{
|
||||
name: "Context with English",
|
||||
ctx: context.WithValue(context.Background(), languageKey, language.English),
|
||||
expected: language.English,
|
||||
},
|
||||
{
|
||||
name: "Context with Spanish",
|
||||
ctx: context.WithValue(context.Background(), languageKey, language.Spanish),
|
||||
expected: language.Spanish,
|
||||
},
|
||||
{
|
||||
name: "Context without language defaults to English",
|
||||
ctx: context.Background(),
|
||||
expected: language.English,
|
||||
},
|
||||
{
|
||||
name: "Context with wrong value type defaults to English",
|
||||
ctx: context.WithValue(context.Background(), languageKey, "invalid"),
|
||||
expected: language.English,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := GetLanguageFromContext(tt.ctx)
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected %v, got %v", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user