package cache import ( "context" "encoding/json" "errors" "sync" "testing" "time" "tercul/internal/domain" "gorm.io/gorm" "github.com/stretchr/testify/require" ) // memCache is a tiny in-memory implementation of platform_cache.Cache for tests. type memCache struct { m map[string][]byte mu sync.RWMutex } func newMemCache() *memCache { return &memCache{m: map[string][]byte{}} } func (c *memCache) 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 *memCache) 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 *memCache) Delete(_ context.Context, key string) error { c.mu.Lock() defer c.mu.Unlock() delete(c.m, key) return nil } func (c *memCache) Clear(_ context.Context) error { c.mu.Lock() c.m = map[string][]byte{} c.mu.Unlock() return nil } func (c *memCache) 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 *memCache) 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 } // dummyWorkRepo implements the minimal parts of domain.WorkRepository we need for tests. type dummyWorkRepo struct { store map[uint]*domain.Work calls int mu sync.Mutex } func newDummyWorkRepo() *dummyWorkRepo { return &dummyWorkRepo{store: map[uint]*domain.Work{}} } func (d *dummyWorkRepo) Create(_ context.Context, entity *domain.Work) error { d.mu.Lock() defer d.mu.Unlock() d.calls++ d.store[entity.ID] = entity return nil } func (d *dummyWorkRepo) CreateInTx(_ context.Context, tx *gorm.DB, entity *domain.Work) error { return errors.New("not implemented") } func (d *dummyWorkRepo) GetByID(_ context.Context, id uuid.UUID) (*domain.Work, error) { d.mu.Lock() defer d.mu.Unlock() d.calls++ w, ok := d.store[id] if !ok { return nil, errors.New("not found") } // return a copy to ensure caching serializes cp := *w return &cp, nil } func (d *dummyWorkRepo) GetByIDWithOptions(_ context.Context, id uint, options *domain.QueryOptions) (*domain.Work, error) { return d.GetByID(context.Background(), id) } func (d *dummyWorkRepo) Update(_ context.Context, entity *domain.Work) 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 *dummyWorkRepo) UpdateInTx(_ context.Context, tx *gorm.DB, entity *domain.Work) error { return errors.New("not implemented") } func (d *dummyWorkRepo) 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 *dummyWorkRepo) DeleteInTx(_ context.Context, tx *gorm.DB, id uint) error { return errors.New("not implemented") } func (d *dummyWorkRepo) List(_ context.Context, page, pageSize int) (*domain.PaginatedResult[domain.Work], error) { return &domain.PaginatedResult[domain.Work]{Items: []domain.Work{}}, nil } func (d *dummyWorkRepo) ListWithOptions(_ context.Context, options *domain.QueryOptions) ([]domain.Work, error) { return nil, nil } func (d *dummyWorkRepo) ListAll(_ context.Context) ([]domain.Work, error) { return nil, nil } func (d *dummyWorkRepo) Count(_ context.Context) (int64, error) { return 0, nil } func (d *dummyWorkRepo) CountWithOptions(_ context.Context, options *domain.QueryOptions) (int64, error) { return 0, nil } func (d *dummyWorkRepo) FindWithPreload(_ context.Context, preloads []string, id uint) (*domain.Work, error) { return d.GetByID(context.Background(), id) } func (d *dummyWorkRepo) GetAllForSync(_ context.Context, batchSize, offset int) ([]domain.Work, error) { return nil, nil } func (d *dummyWorkRepo) Exists(_ context.Context, id uint) (bool, error) { _, ok := d.store[id] return ok, nil } func (d *dummyWorkRepo) BeginTx(_ context.Context) (*gorm.DB, error) { return nil, nil } func (d *dummyWorkRepo) WithTx(_ context.Context, fn func(tx *gorm.DB) error) error { return fn(nil) } func (d *dummyWorkRepo) FindByTitle(_ context.Context, title string) ([]domain.Work, error) { return nil, nil } func (d *dummyWorkRepo) FindByAuthor(_ context.Context, authorID uint) ([]domain.Work, error) { return nil, nil } func (d *dummyWorkRepo) FindByCategory(_ context.Context, categoryID uint) ([]domain.Work, error) { return nil, nil } func (d *dummyWorkRepo) GetWithTranslations(_ context.Context, id uint) (*domain.Work, error) { return d.GetByID(context.Background(), id) } func (d *dummyWorkRepo) GetWithAssociations(_ context.Context, id uint) (*domain.Work, error) { return d.GetByID(context.Background(), id) } func (d *dummyWorkRepo) GetWithAssociationsInTx(_ context.Context, tx *gorm.DB, id uint) (*domain.Work, error) { return d.GetByID(context.Background(), id) } func (d *dummyWorkRepo) ListWithTranslations(_ context.Context, page, pageSize int) (*domain.PaginatedResult[domain.Work], error) { // return a simple paginated result of all works items := []domain.Work{} for _, w := range d.store { items = append(items, *w) } start := (page - 1) * pageSize if start < 0 { start = 0 } end := start + pageSize if start >= len(items) { return &domain.PaginatedResult[domain.Work]{Items: []domain.Work{}}, nil } if end > len(items) { end = len(items) } return &domain.PaginatedResult[domain.Work]{Items: items[start:end], TotalCount: int64(len(items)), Page: page, PageSize: pageSize}, nil } func (d *dummyWorkRepo) FindByLanguage(_ context.Context, language string, page, pageSize int) (*domain.PaginatedResult[domain.Work], error) { items := []domain.Work{} for _, w := range d.store { if w.Language == language { items = append(items, *w) } } start := (page - 1) * pageSize if start < 0 { start = 0 } end := start + pageSize if start >= len(items) { return &domain.PaginatedResult[domain.Work]{Items: []domain.Work{}}, nil } if end > len(items) { end = len(items) } return &domain.PaginatedResult[domain.Work]{Items: items[start:end], TotalCount: int64(len(items)), Page: page, PageSize: pageSize}, nil } func (d *dummyWorkRepo) IsAuthor(_ context.Context, workID uint, authorID uint) (bool, error) { return false, nil } func (d *dummyWorkRepo) ListByCollectionID(_ context.Context, collectionID uint) ([]domain.Work, error) { return nil, nil } func TestCachedWork_GetByID_Caches(t *testing.T) { ctx := context.Background() d := newDummyWorkRepo() d.store[1] = &domain.Work{Title: "initial", TranslatableModel: domain.TranslatableModel{BaseModel: domain.BaseModel{ID: 1}}} mc := newMemCache() cw := NewCachedWorkRepository(d, mc, nil) w, err := cw.GetByID(ctx, 1) require.NoError(t, err) require.Equal(t, "initial", w.Title) require.Equal(t, 1, d.calls) // second read should hit cache and not increment inner calls w2, err := cw.GetByID(ctx, 1) require.NoError(t, err) require.Equal(t, "initial", w2.Title) require.Equal(t, 1, d.calls) } func TestCachedWork_Update_Invalidates(t *testing.T) { ctx := context.Background() d := newDummyWorkRepo() d.store[1] = &domain.Work{Title: "initial", TranslatableModel: domain.TranslatableModel{BaseModel: domain.BaseModel{ID: 1}}} mc := newMemCache() cw := NewCachedWorkRepository(d, mc, nil) _, err := cw.GetByID(ctx, 1) require.NoError(t, err) require.Equal(t, 1, d.calls) // Update underlying entity d.store[1] = &domain.Work{Title: "updated", TranslatableModel: domain.TranslatableModel{BaseModel: domain.BaseModel{ID: 1}}} err = cw.Update(ctx, d.store[1]) require.NoError(t, err) // Next GetByID should call inner again (cache invalidated) w, err := cw.GetByID(ctx, 1) require.NoError(t, err) require.Equal(t, "updated", w.Title) require.Equal(t, 3, d.calls, "expected inner to be called again for update invalidation") } func TestCachedWork_Delete_Invalidates(t *testing.T) { ctx := context.Background() d := newDummyWorkRepo() d.store[1] = &domain.Work{Title: "initial", TranslatableModel: domain.TranslatableModel{BaseModel: domain.BaseModel{ID: 1}}} mc := newMemCache() cw := NewCachedWorkRepository(d, mc, nil) _, err := cw.GetByID(ctx, 1) require.NoError(t, err) // delete err = cw.Delete(ctx, 1) require.NoError(t, err) _, err = cw.GetByID(ctx, 1) require.Error(t, err) }