package cache import ( "context" "encoding/json" "errors" "sync" "testing" "time" "tercul/internal/domain" "gorm.io/gorm" "github.com/stretchr/testify/require" ) // memCacheT - small in-memory cache for translation tests type memCacheT struct { m map[string][]byte mu sync.RWMutex } func newMemCacheT() *memCacheT { return &memCacheT{m: map[string][]byte{}} } func (c *memCacheT) Get(_ context.Context, key string, value interface{}) error { c.mu.RLock() defer c.mu.RUnlock() b, ok := c.m[key] if !ok { return errors.New("cache miss") } return json.Unmarshal(b, value) } func (c *memCacheT) Set(_ context.Context, key string, value interface{}, expiration time.Duration) error { b, err := json.Marshal(value) if err != nil { return err } c.mu.Lock() defer c.mu.Unlock() c.m[key] = b return nil } func (c *memCacheT) Delete(_ context.Context, key string) error { c.mu.Lock() delete(c.m, key) c.mu.Unlock() return nil } func (c *memCacheT) Clear(_ context.Context) error { c.mu.Lock() c.m = map[string][]byte{} c.mu.Unlock() return nil } func (c *memCacheT) GetMulti(_ context.Context, keys []string) (map[string][]byte, error) { res := map[string][]byte{} c.mu.RLock() defer c.mu.RUnlock() for _, k := range keys { if v, ok := c.m[k]; ok { res[k] = v } } return res, nil } func (c *memCacheT) SetMulti(_ context.Context, items map[string]interface{}, expiration time.Duration) error { for k, v := range items { b, _ := json.Marshal(v) c.m[k] = b } return nil } // dummyTranslationRepo implements domain.TranslationRepository minimal functionality for tests type dummyTranslationRepo struct { store map[uint]*domain.Translation calls int mu sync.Mutex } func newDummyTranslationRepo() *dummyTranslationRepo { return &dummyTranslationRepo{store: map[uint]*domain.Translation{}} } func (d *dummyTranslationRepo) Create(_ context.Context, entity *domain.Translation) error { d.mu.Lock() defer d.mu.Unlock() d.calls++ d.store[entity.ID] = entity return nil } func (d *dummyTranslationRepo) CreateInTx(_ context.Context, tx *gorm.DB, entity *domain.Translation) error { return errors.New("not implemented") } func (d *dummyTranslationRepo) GetByID(_ context.Context, id uuid.UUID) (*domain.Translation, error) { d.mu.Lock() defer d.mu.Unlock() d.calls++ tr, ok := d.store[id] if !ok { return nil, errors.New("not found") } cp := *tr return &cp, nil } func (d *dummyTranslationRepo) GetByIDWithOptions(_ context.Context, id uint, options *domain.QueryOptions) (*domain.Translation, error) { return d.GetByID(context.Background(), id) } func (d *dummyTranslationRepo) Update(_ context.Context, entity *domain.Translation) error { d.mu.Lock() defer d.mu.Unlock() d.calls++ if _, ok := d.store[entity.ID]; !ok { return errors.New("not found") } d.store[entity.ID] = entity return nil } func (d *dummyTranslationRepo) UpdateInTx(_ context.Context, tx *gorm.DB, entity *domain.Translation) error { return errors.New("not implemented") } func (d *dummyTranslationRepo) Delete(_ context.Context, id uint) error { d.mu.Lock() defer d.mu.Unlock() d.calls++ if _, ok := d.store[id]; !ok { return errors.New("not found") } delete(d.store, id) return nil } func (d *dummyTranslationRepo) DeleteInTx(_ context.Context, tx *gorm.DB, id uint) error { return errors.New("not implemented") } func (d *dummyTranslationRepo) List(_ context.Context, page, pageSize int) (*domain.PaginatedResult[domain.Translation], error) { return &domain.PaginatedResult[domain.Translation]{Items: []domain.Translation{}}, nil } func (d *dummyTranslationRepo) ListWithOptions(_ context.Context, options *domain.QueryOptions) ([]domain.Translation, error) { return nil, nil } func (d *dummyTranslationRepo) ListAll(_ context.Context) ([]domain.Translation, error) { return nil, nil } func (d *dummyTranslationRepo) Count(_ context.Context) (int64, error) { return 0, nil } func (d *dummyTranslationRepo) CountWithOptions(_ context.Context, options *domain.QueryOptions) (int64, error) { return 0, nil } func (d *dummyTranslationRepo) FindWithPreload(_ context.Context, preloads []string, id uint) (*domain.Translation, error) { return d.GetByID(context.Background(), id) } func (d *dummyTranslationRepo) GetAllForSync(_ context.Context, batchSize, offset int) ([]domain.Translation, error) { return nil, nil } func (d *dummyTranslationRepo) Exists(_ context.Context, id uint) (bool, error) { _, ok := d.store[id] return ok, nil } func (d *dummyTranslationRepo) BeginTx(_ context.Context) (*gorm.DB, error) { return nil, nil } func (d *dummyTranslationRepo) WithTx(_ context.Context, fn func(tx *gorm.DB) error) error { return fn(nil) } func (d *dummyTranslationRepo) ListByWorkID(_ context.Context, workID uint) ([]domain.Translation, error) { d.mu.Lock() defer d.mu.Unlock() d.calls++ res := []domain.Translation{} for _, v := range d.store { if v.TranslatableID == workID { res = append(res, *v) } } return res, nil } func (d *dummyTranslationRepo) ListByWorkIDPaginated(_ context.Context, workID uint, language *string, page, pageSize int) (*domain.PaginatedResult[domain.Translation], error) { all, _ := d.ListByWorkID(context.Background(), workID) return &domain.PaginatedResult[domain.Translation]{Items: all, TotalCount: int64(len(all)), Page: page, PageSize: pageSize}, nil } func (d *dummyTranslationRepo) ListByEntity(_ context.Context, entityType string, entityID uint) ([]domain.Translation, error) { return nil, nil } func (d *dummyTranslationRepo) ListByTranslatorID(_ context.Context, translatorID uint) ([]domain.Translation, error) { return nil, nil } func (d *dummyTranslationRepo) ListByStatus(_ context.Context, status domain.TranslationStatus) ([]domain.Translation, error) { return nil, nil } func (d *dummyTranslationRepo) Upsert(_ context.Context, translation *domain.Translation) error { d.mu.Lock() defer d.mu.Unlock() if translation.ID == 0 { translation.ID = uint(len(d.store) + 1) } d.store[translation.ID] = translation d.calls++ return nil } func TestCachedTranslation_GetByID_Caches(t *testing.T) { ctx := context.Background() d := newDummyTranslationRepo() d.store[1] = &domain.Translation{BaseModel: domain.BaseModel{ID: 1}, TranslatableID: 10, Language: "en", Title: "T1"} mc := newMemCacheT() ct := NewCachedTranslationRepository(d, mc, nil) tr, err := ct.GetByID(ctx, 1) require.NoError(t, err) require.Equal(t, "T1", tr.Title) require.Equal(t, 1, d.calls) // second read should hit cache tr2, err := ct.GetByID(ctx, 1) require.NoError(t, err) require.Equal(t, "T1", tr2.Title) require.Equal(t, 1, d.calls) } func TestCachedTranslation_ListByWorkID_Caches(t *testing.T) { ctx := context.Background() d := newDummyTranslationRepo() d.store[11] = &domain.Translation{BaseModel: domain.BaseModel{ID: 11}, TranslatableID: 100, Language: "en", Title: "A"} d.store[12] = &domain.Translation{BaseModel: domain.BaseModel{ID: 12}, TranslatableID: 100, Language: "fr", Title: "B"} mc := newMemCacheT() ct := NewCachedTranslationRepository(d, mc, nil) list, err := ct.ListByWorkID(ctx, 100) require.NoError(t, err) require.Len(t, list, 2) require.Equal(t, 1, d.calls) // second call should hit cache list2, err := ct.ListByWorkID(ctx, 100) require.NoError(t, err) require.Len(t, list2, 2) require.Equal(t, 1, d.calls) } func TestCachedTranslation_Update_Invalidates(t *testing.T) { ctx := context.Background() d := newDummyTranslationRepo() d.store[1] = &domain.Translation{BaseModel: domain.BaseModel{ID: 1}, TranslatableID: 10, Language: "en", Title: "Old"} mc := newMemCacheT() ct := NewCachedTranslationRepository(d, mc, nil) _, err := ct.GetByID(ctx, 1) require.NoError(t, err) // update underlying d.store[1] = &domain.Translation{BaseModel: domain.BaseModel{ID: 1}, TranslatableID: 10, Language: "en", Title: "New"} err = ct.Update(ctx, d.store[1]) require.NoError(t, err) tr, err := ct.GetByID(ctx, 1) require.NoError(t, err) require.Equal(t, "New", tr.Title) require.Equal(t, 3, d.calls) }