package handler import ( "bugulma/backend/internal/domain" "bugulma/backend/internal/service" "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "testing" "github.com/gin-gonic/gin" ) type inMemoryLocalizationService struct { data map[string]string } func newInMemoryLocalizationService() *inMemoryLocalizationService { return &inMemoryLocalizationService{data: map[string]string{}} } func (s *inMemoryLocalizationService) key(entityType, entityID, field, locale string) string { return entityType + ":" + entityID + ":" + field + ":" + locale } func (s *inMemoryLocalizationService) GetLocalizedValue(entityType, entityID, field, locale string) (string, error) { return s.data[s.key(entityType, entityID, field, locale)], nil } func (s *inMemoryLocalizationService) SetLocalizedValue(entityType, entityID, field, locale, value string) error { s.data[s.key(entityType, entityID, field, locale)] = value return nil } func (s *inMemoryLocalizationService) GetAllLocalizedValues(entityType, entityID string) (map[string]map[string]string, error) { return map[string]map[string]string{}, nil } func (s *inMemoryLocalizationService) GetLocalizedEntity(entityType, entityID, locale string) (map[string]string, error) { return map[string]string{}, nil } func (s *inMemoryLocalizationService) GetSupportedLocalesForEntity(entityType, entityID string) ([]string, error) { return []string{"ru", "en", "tt"}, nil } func (s *inMemoryLocalizationService) DeleteLocalizedValue(entityType, entityID, field, locale string) error { delete(s.data, s.key(entityType, entityID, field, locale)) return nil } func (s *inMemoryLocalizationService) BulkSetLocalizedValues(entityType, entityID string, values map[string]map[string]string) error { for field, locales := range values { for locale, value := range locales { s.data[s.key(entityType, entityID, field, locale)] = value } } return nil } func (s *inMemoryLocalizationService) GetAllLocales() ([]string, error) { return []string{"ru", "en", "tt"}, nil } func (s *inMemoryLocalizationService) SearchLocalizations(query, locale string, limit int) ([]*domain.Localization, error) { return []*domain.Localization{}, nil } func (s *inMemoryLocalizationService) ApplyLocalizationToEntity(entity domain.Localizable, locale string) error { return nil } type inMemoryLocalizationRepo struct { entityFields map[string]map[string]map[string]string // entityType -> entityID -> field -> ruValue locValues map[string]string // entityType:entityID:field:locale -> value } func newInMemoryLocalizationRepo() *inMemoryLocalizationRepo { return &inMemoryLocalizationRepo{ entityFields: map[string]map[string]map[string]string{}, locValues: map[string]string{}, } } func (r *inMemoryLocalizationRepo) locKey(entityType, entityID, field, locale string) string { return entityType + ":" + entityID + ":" + field + ":" + locale } func (r *inMemoryLocalizationRepo) Create(ctx context.Context, loc *domain.Localization) error { r.locValues[r.locKey(loc.EntityType, loc.EntityID, loc.Field, loc.Locale)] = loc.Value return nil } func (r *inMemoryLocalizationRepo) GetByEntityAndField(ctx context.Context, entityType, entityID, field, locale string) (*domain.Localization, error) { if v, ok := r.locValues[r.locKey(entityType, entityID, field, locale)]; ok { return &domain.Localization{EntityType: entityType, EntityID: entityID, Field: field, Locale: locale, Value: v}, nil } return nil, nil } func (r *inMemoryLocalizationRepo) GetAllByEntity(ctx context.Context, entityType, entityID string) ([]*domain.Localization, error) { return []*domain.Localization{}, nil } func (r *inMemoryLocalizationRepo) Update(ctx context.Context, loc *domain.Localization) error { r.locValues[r.locKey(loc.EntityType, loc.EntityID, loc.Field, loc.Locale)] = loc.Value return nil } func (r *inMemoryLocalizationRepo) Delete(ctx context.Context, id string) error { return nil } func (r *inMemoryLocalizationRepo) GetByEntityTypeAndLocale(ctx context.Context, entityType, locale string) ([]*domain.Localization, error) { return []*domain.Localization{}, nil } func (r *inMemoryLocalizationRepo) GetAllLocales(ctx context.Context) ([]string, error) { return []string{"ru", "en", "tt"}, nil } func (r *inMemoryLocalizationRepo) GetSupportedLocalesForEntity(ctx context.Context, entityType, entityID string) ([]string, error) { return []string{"ru", "en", "tt"}, nil } func (r *inMemoryLocalizationRepo) BulkCreate(ctx context.Context, localizations []*domain.Localization) error { for _, loc := range localizations { _ = r.Create(ctx, loc) } return nil } func (r *inMemoryLocalizationRepo) BulkDelete(ctx context.Context, ids []string) error { return nil } func (r *inMemoryLocalizationRepo) SearchLocalizations(ctx context.Context, query string, locale string, limit int) ([]*domain.Localization, error) { return []*domain.Localization{}, nil } func (r *inMemoryLocalizationRepo) GetTranslationReuseCandidates(ctx context.Context, entityType, field, locale string) ([]domain.ReuseCandidate, error) { return []domain.ReuseCandidate{}, nil } func (r *inMemoryLocalizationRepo) GetEntitiesNeedingTranslation(ctx context.Context, entityType, field, targetLocale string, limit int) ([]string, error) { ids := []string{} entities := r.entityFields[entityType] for entityID, fields := range entities { ruValue := fields[field] if ruValue == "" { continue } if r.locValues[r.locKey(entityType, entityID, field, targetLocale)] == "" { ids = append(ids, entityID) if limit > 0 && len(ids) >= limit { break } } } return ids, nil } func (r *inMemoryLocalizationRepo) FindExistingTranslationByRussianText(ctx context.Context, entityType, field, targetLocale, russianText string) (string, error) { return "", nil } func (r *inMemoryLocalizationRepo) GetTranslationCountsByEntity(ctx context.Context) (map[string]map[string]int, error) { return map[string]map[string]int{}, nil } func (r *inMemoryLocalizationRepo) GetEntityFieldValue(ctx context.Context, entityType, entityID, field string) (string, error) { if r.entityFields[entityType] == nil || r.entityFields[entityType][entityID] == nil { return "", nil } return r.entityFields[entityType][entityID][field], nil } func TestI18nData_GetMissingTranslations_InvalidLocale(t *testing.T) { gin.SetMode(gin.TestMode) repo := newInMemoryLocalizationRepo() locSvc := newInMemoryLocalizationService() cacheSvc := service.NewTranslationCacheService(repo, locSvc) translationSvc := service.NewTranslationService("http://localhost:0", "") i18nSvc := service.NewI18nService(locSvc, repo, translationSvc, cacheSvc) h := NewI18nHandler(i18nSvc) r := gin.New() r.GET("/api/v1/admin/i18n/data/:entityType/missing", h.GetMissingTranslations) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/i18n/data/organization/missing?locale=xx", nil) r.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String()) } } func TestI18nData_GetMissingTranslations_Success(t *testing.T) { gin.SetMode(gin.TestMode) repo := newInMemoryLocalizationRepo() repo.entityFields["organization"] = map[string]map[string]string{ "org-1": {"name": "Имя 1", "description": "Описание 1"}, "org-2": {"name": "Имя 2", "description": "Описание 2"}, } // Pretend org-1 name already translated repo.locValues[repo.locKey("organization", "org-1", "name", "en")] = "Name 1" locSvc := newInMemoryLocalizationService() cacheSvc := service.NewTranslationCacheService(repo, locSvc) translationSvc := service.NewTranslationService("http://localhost:0", "") i18nSvc := service.NewI18nService(locSvc, repo, translationSvc, cacheSvc) h := NewI18nHandler(i18nSvc) r := gin.New() r.GET("/api/v1/admin/i18n/data/:entityType/missing", h.GetMissingTranslations) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/i18n/data/organization/missing?locale=en&fields=name,description&limit=100", nil) r.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var resp struct { Total int `json:"total"` Counts map[string]int `json:"counts"` } if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to parse response: %v", err) } // name missing only for org-2, description missing for both if resp.Counts["name"] != 1 { t.Fatalf("expected name missing=1, got %d", resp.Counts["name"]) } if resp.Counts["description"] != 2 { t.Fatalf("expected description missing=2, got %d", resp.Counts["description"]) } if resp.Total != 3 { t.Fatalf("expected total=3, got %d", resp.Total) } } func TestI18nData_BulkTranslateData_Success(t *testing.T) { gin.SetMode(gin.TestMode) // Fake Ollama server ollama := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/generate" { w.WriteHeader(http.StatusNotFound) return } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(service.TranslationResponse{ Model: "test", Response: "TRANSLATED", Done: true, }) })) defer ollama.Close() repo := newInMemoryLocalizationRepo() repo.entityFields["organization"] = map[string]map[string]string{ "org-1": {"name": "Имя 1"}, } locSvc := newInMemoryLocalizationService() cacheSvc := service.NewTranslationCacheService(repo, locSvc) translationSvc := service.NewTranslationService(ollama.URL, "") i18nSvc := service.NewI18nService(locSvc, repo, translationSvc, cacheSvc) h := NewI18nHandler(i18nSvc) r := gin.New() r.POST("/api/v1/admin/i18n/data/bulk-translate", h.BulkTranslateData) payload := map[string]any{ "entityType": "organization", "entityIDs": []string{"org-1"}, "targetLocale": "en", "fields": []string{"name"}, } b, _ := json.Marshal(payload) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/i18n/data/bulk-translate", bytes.NewReader(b)) req.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var resp struct { Translated int `json:"translated"` Results map[string]map[string]string `json:"results"` } if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to parse response: %v", err) } if resp.Translated != 1 { t.Fatalf("expected translated=1, got %d", resp.Translated) } if resp.Results["org-1"]["name"] != "TRANSLATED" { t.Fatalf("expected result TRANSLATED, got %q", resp.Results["org-1"]["name"]) } // Also persisted via LocalizationService if v, _ := locSvc.GetLocalizedValue("organization", "org-1", "name", "en"); v != "TRANSLATED" { t.Fatalf("expected persisted TRANSLATED, got %q", v) } }