package contentmarkerser import ( "bytes" "encoding/json" "errors" "sync" "testing" "time" "91porn-server/common/constant/redisconst" ) type markerCacheStub struct { data Response getErr error setData Response setCalls int latestListCached bool latestListReviewAt *time.Time } type markerRaceCache struct { mu sync.Mutex payload string exists bool } func (c *markerRaceCache) GetWithJson2Any(_ string, val any) error { c.mu.Lock() defer c.mu.Unlock() if !c.exists { return errors.New("cache miss") } return json.Unmarshal([]byte(c.payload), val) } func (c *markerRaceCache) SetNX(_ string, val interface{}, _ time.Duration) (bool, error) { c.mu.Lock() defer c.mu.Unlock() if c.exists { return false, nil } payload, ok := val.(string) if !ok { return false, errors.New("cache value is not JSON text") } c.payload = payload c.exists = true return true, nil } func (c *markerCacheStub) GetWithJson2Any(key string, val any) error { if key == redisconst.GetMostNewModuleVideoListKey( homeLatestListSortType, homeLatestListPageNumber, homeLatestListPageSize, ) { if !c.latestListCached { return errors.New("latest list cache miss") } data := cachedHomeLatestList{} if c.latestListReviewAt != nil { data.Videos = append(data.Videos, struct { ReviewAt time.Time `json:"reviewAt"` }{ReviewAt: *c.latestListReviewAt}) } payload, err := json.Marshal(data) if err != nil { return err } return json.Unmarshal(payload, val) } if c.getErr != nil { return c.getErr } payload, err := json.Marshal(c.data) if err != nil { return err } return json.Unmarshal(payload, val) } func (c *markerCacheStub) SetNX(_ string, val interface{}, _ time.Duration) (bool, error) { c.setCalls++ payload, ok := val.(string) if !ok { return false, errors.New("cache value is not JSON text") } if err := json.Unmarshal([]byte(payload), &c.setData); err != nil { return false, err } return true, nil } func TestResponseOmitsTodayLatestAt(t *testing.T) { payload, err := json.Marshal(Response{}) if err != nil { t.Fatalf("json.Marshal(Response{}) error = %v", err) } if bytes.Contains(payload, []byte("todayLatestAt")) { t.Fatalf("response still contains removed todayLatestAt field: %s", payload) } } func TestGetCachedReturnsCachedMarkers(t *testing.T) { latest := time.Date(2026, 8, 4, 11, 30, 0, 123000000, time.FixedZone("CST", 8*60*60)) cache := &markerCacheStub{data: Response{HomeLatestAt: &latest}} got, err := GetCached(time.Now(), cache) if err != nil { t.Fatalf("GetCached() error = %v", err) } if got.HomeLatestAt == nil || !got.HomeLatestAt.Equal(latest) { t.Fatalf("GetCached() homeLatestAt = %v, want %v", got.HomeLatestAt, latest) } if cache.setCalls != 0 { t.Fatalf("GetCached() cache writes = %d, want 0", cache.setCalls) } } func TestReadCachedReportsMiss(t *testing.T) { cache := &markerCacheStub{getErr: errors.New("cache miss")} if _, ok := ReadCached(cache); ok { t.Fatal("ReadCached() hit = true, want false") } if _, ok := ReadCached(nil); ok { t.Fatal("ReadCached(nil) hit = true, want false") } } func TestGetCachedUsesHomeLatestAtFromCachedList(t *testing.T) { markerLatest := time.Date(2026, 8, 4, 11, 30, 0, 0, time.UTC) listLatest := markerLatest.Add(-2 * time.Minute) cache := &markerCacheStub{ data: Response{HomeLatestAt: &markerLatest}, latestListCached: true, latestListReviewAt: &listLatest, } got, err := GetCached(time.Now(), cache) if err != nil { t.Fatalf("GetCached() error = %v", err) } if got.HomeLatestAt == nil || !got.HomeLatestAt.Equal(listLatest) { t.Fatalf("GetCached() homeLatestAt = %v, want cached list time %v", got.HomeLatestAt, listLatest) } } func TestGetCachedClearsHomeLatestAtForCachedEmptyList(t *testing.T) { markerLatest := time.Date(2026, 8, 4, 11, 30, 0, 0, time.UTC) cache := &markerCacheStub{ data: Response{HomeLatestAt: &markerLatest}, latestListCached: true, } got, err := GetCached(time.Now(), cache) if err != nil { t.Fatalf("GetCached() error = %v", err) } if got.HomeLatestAt != nil { t.Fatalf("GetCached() homeLatestAt = %v, want nil for cached empty list", got.HomeLatestAt) } } func TestGetCachedLoadsAndWritesCacheBeforeReturning(t *testing.T) { latest := time.Date(2026, 8, 4, 11, 31, 0, 0, time.UTC) cache := &markerCacheStub{getErr: errors.New("cache miss")} loadCalls := 0 load := func(time.Time) (Response, error) { loadCalls++ return Response{HomeLatestAt: &latest}, nil } got, err := getCached(time.Now(), cache, load) if err != nil { t.Fatalf("getCached() error = %v", err) } if loadCalls != 1 { t.Fatalf("getCached() loader calls = %d, want 1", loadCalls) } if cache.setCalls != 1 || cache.setData.HomeLatestAt == nil { t.Fatalf("getCached() cache write = (%d, %v), want one marker write", cache.setCalls, cache.setData.HomeLatestAt) } if got.HomeLatestAt == nil || !got.HomeLatestAt.Equal(latest) { t.Fatalf("getCached() homeLatestAt = %v, want %v", got.HomeLatestAt, latest) } } func TestGetCachedConcurrentMissReturnsWinningSnapshot(t *testing.T) { oldLatest := time.Date(2026, 8, 4, 11, 30, 0, 0, time.UTC) newLatest := oldLatest.Add(time.Minute) cache := &markerRaceCache{} oldLoaderStarted := make(chan struct{}) releaseOldLoader := make(chan struct{}) type outcome struct { data Response err error } oldOutcome := make(chan outcome, 1) go func() { data, err := getCached(time.Now(), cache, func(time.Time) (Response, error) { close(oldLoaderStarted) <-releaseOldLoader return Response{HomeLatestAt: &oldLatest}, nil }) oldOutcome <- outcome{data: data, err: err} }() <-oldLoaderStarted newData, err := getCached(time.Now(), cache, func(time.Time) (Response, error) { return Response{HomeLatestAt: &newLatest}, nil }) if err != nil { t.Fatalf("new getCached() error = %v", err) } close(releaseOldLoader) oldResult := <-oldOutcome if oldResult.err != nil { t.Fatalf("old getCached() error = %v", oldResult.err) } for name, data := range map[string]Response{"new": newData, "old": oldResult.data} { if data.HomeLatestAt == nil || !data.HomeLatestAt.Equal(newLatest) { t.Fatalf("%s getCached() homeLatestAt = %v, want winning %v", name, data.HomeLatestAt, newLatest) } } }