323 lines
9.1 KiB
Go
323 lines
9.1 KiB
Go
package moduleconfmod
|
|
|
|
import (
|
|
"errors"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"91porn-server/models/commod"
|
|
|
|
"go.mongodb.org/mongo-driver/bson/primitive"
|
|
"go.mongodb.org/mongo-driver/mongo"
|
|
)
|
|
|
|
type moduleCacheTestClock struct {
|
|
mu sync.Mutex
|
|
now time.Time
|
|
}
|
|
|
|
func (c *moduleCacheTestClock) Now() time.Time {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return c.now
|
|
}
|
|
|
|
func (c *moduleCacheTestClock) Advance(duration time.Duration) {
|
|
c.mu.Lock()
|
|
c.now = c.now.Add(duration)
|
|
c.mu.Unlock()
|
|
}
|
|
|
|
func TestModuleSnapshotCacheConcurrentReadersShareOneLoad(t *testing.T) {
|
|
clock := &moduleCacheTestClock{now: time.Date(2026, 7, 31, 8, 0, 0, 0, time.UTC)}
|
|
moduleID := primitive.NewObjectID()
|
|
var calls atomic.Int32
|
|
var startedOnce sync.Once
|
|
started := make(chan struct{})
|
|
release := make(chan struct{})
|
|
cache := newModuleSnapshotCache(func() ([]ModuleConf, error) {
|
|
calls.Add(1)
|
|
startedOnce.Do(func() { close(started) })
|
|
<-release
|
|
return []ModuleConf{{ID: moduleID, SubModuleName: "cached"}}, nil
|
|
}, clock.Now, time.Minute, time.Second)
|
|
|
|
const readers = 32
|
|
errs := make(chan error, readers)
|
|
var wg sync.WaitGroup
|
|
wg.Add(readers)
|
|
for i := 0; i < readers; i++ {
|
|
go func() {
|
|
defer wg.Done()
|
|
snapshot, err := cache.get()
|
|
if err != nil {
|
|
errs <- err
|
|
return
|
|
}
|
|
module, ok := snapshot.findByID(moduleID)
|
|
if !ok || module.SubModuleName != "cached" {
|
|
errs <- errors.New("reader received an unexpected snapshot")
|
|
}
|
|
}()
|
|
}
|
|
|
|
<-started
|
|
close(release)
|
|
wg.Wait()
|
|
close(errs)
|
|
for err := range errs {
|
|
t.Error(err)
|
|
}
|
|
if got := calls.Load(); got != 1 {
|
|
t.Fatalf("loader calls = %d, want 1", got)
|
|
}
|
|
}
|
|
|
|
func TestModuleSnapshotCacheInitialFailureReturnsError(t *testing.T) {
|
|
wantErr := errors.New("mongo unavailable")
|
|
cache := newModuleSnapshotCache(func() ([]ModuleConf, error) {
|
|
return nil, wantErr
|
|
}, time.Now, time.Minute, time.Second)
|
|
|
|
if _, err := cache.get(); !errors.Is(err, wantErr) {
|
|
t.Fatalf("get() error = %v, want %v", err, wantErr)
|
|
}
|
|
}
|
|
|
|
func TestModuleSnapshotCacheUsesLastKnownGoodAndRetries(t *testing.T) {
|
|
clock := &moduleCacheTestClock{now: time.Date(2026, 7, 31, 8, 0, 0, 0, time.UTC)}
|
|
moduleID := primitive.NewObjectID()
|
|
var calls atomic.Int32
|
|
cache := newModuleSnapshotCache(func() ([]ModuleConf, error) {
|
|
switch calls.Add(1) {
|
|
case 1:
|
|
return []ModuleConf{{ID: moduleID, SubModuleName: "v1"}}, nil
|
|
case 2:
|
|
return nil, errors.New("temporary mongo failure")
|
|
default:
|
|
return []ModuleConf{{ID: moduleID, SubModuleName: "v2"}}, nil
|
|
}
|
|
}, clock.Now, 10*time.Second, time.Second)
|
|
|
|
assertCachedModuleName(t, cache, moduleID, "v1")
|
|
clock.Advance(11 * time.Second)
|
|
assertCachedModuleName(t, cache, moduleID, "v1")
|
|
assertCachedModuleName(t, cache, moduleID, "v1")
|
|
if got := calls.Load(); got != 2 {
|
|
t.Fatalf("loader calls during retry delay = %d, want 2", got)
|
|
}
|
|
|
|
clock.Advance(2 * time.Second)
|
|
assertCachedModuleName(t, cache, moduleID, "v2")
|
|
if got := calls.Load(); got != 3 {
|
|
t.Fatalf("loader calls after retry delay = %d, want 3", got)
|
|
}
|
|
}
|
|
|
|
func TestModuleSnapshotCacheInvalidateForcesReload(t *testing.T) {
|
|
moduleID := primitive.NewObjectID()
|
|
var calls atomic.Int32
|
|
cache := newModuleSnapshotCache(func() ([]ModuleConf, error) {
|
|
version := calls.Add(1)
|
|
return []ModuleConf{{
|
|
ID: moduleID,
|
|
SubModuleName: "v" + string(rune('0'+version)),
|
|
}}, nil
|
|
}, time.Now, time.Hour, time.Second)
|
|
|
|
assertCachedModuleName(t, cache, moduleID, "v1")
|
|
cache.invalidate()
|
|
assertCachedModuleName(t, cache, moduleID, "v2")
|
|
if got := calls.Load(); got != 2 {
|
|
t.Fatalf("loader calls = %d, want 2", got)
|
|
}
|
|
}
|
|
|
|
func TestModuleSnapshotCacheInvalidateDuringLoadDiscardsStaleResult(t *testing.T) {
|
|
moduleID := primitive.NewObjectID()
|
|
var calls atomic.Int32
|
|
firstStarted := make(chan struct{})
|
|
releaseFirst := make(chan struct{})
|
|
cache := newModuleSnapshotCache(func() ([]ModuleConf, error) {
|
|
call := calls.Add(1)
|
|
if call == 1 {
|
|
close(firstStarted)
|
|
<-releaseFirst
|
|
return []ModuleConf{{ID: moduleID, SubModuleName: "stale"}}, nil
|
|
}
|
|
return []ModuleConf{{ID: moduleID, SubModuleName: "fresh"}}, nil
|
|
}, time.Now, time.Hour, time.Second)
|
|
|
|
result := make(chan string, 1)
|
|
errs := make(chan error, 1)
|
|
go func() {
|
|
snapshot, err := cache.get()
|
|
if err != nil {
|
|
errs <- err
|
|
return
|
|
}
|
|
module, _ := snapshot.findByID(moduleID)
|
|
result <- module.SubModuleName
|
|
}()
|
|
|
|
<-firstStarted
|
|
cache.invalidate()
|
|
close(releaseFirst)
|
|
|
|
select {
|
|
case err := <-errs:
|
|
t.Fatal(err)
|
|
case got := <-result:
|
|
if got != "fresh" {
|
|
t.Fatalf("module name = %q, want fresh", got)
|
|
}
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("cache reload timed out")
|
|
}
|
|
if got := calls.Load(); got != 2 {
|
|
t.Fatalf("loader calls = %d, want 2", got)
|
|
}
|
|
}
|
|
|
|
func TestModuleSnapshotScheduleBoundariesDoNotRequireReload(t *testing.T) {
|
|
base := time.Date(2026, 7, 31, 8, 0, 0, 0, time.UTC)
|
|
onlineAt := base.Add(time.Hour)
|
|
offlineAt := base.Add(2 * time.Hour)
|
|
moduleID := primitive.NewObjectID()
|
|
var calls atomic.Int32
|
|
cache := newModuleSnapshotCache(func() ([]ModuleConf, error) {
|
|
calls.Add(1)
|
|
return []ModuleConf{{
|
|
ID: moduleID,
|
|
Status: 1,
|
|
OnlineAt: &onlineAt,
|
|
OfflineAt: &offlineAt,
|
|
SearchOnlyWhenInactive: true,
|
|
}}, nil
|
|
}, func() time.Time { return base }, time.Hour, time.Second)
|
|
|
|
snapshot, err := cache.get()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
assertStringSetContains(t, snapshot.excludedVideoModuleIDs(onlineAt.Add(-time.Nanosecond), false), moduleID.Hex(), true)
|
|
assertStringSetContains(t, snapshot.excludedVideoModuleIDs(onlineAt, false), moduleID.Hex(), false)
|
|
assertStringSetContains(t, snapshot.excludedVideoModuleIDs(offlineAt, false), moduleID.Hex(), true)
|
|
if _, ok := snapshot.blockedOutsideSearchModuleIDs([]primitive.ObjectID{moduleID}, onlineAt.Add(-time.Nanosecond))[moduleID.Hex()]; !ok {
|
|
t.Fatal("module should be blocked before onlineAt")
|
|
}
|
|
if _, ok := snapshot.blockedOutsideSearchModuleIDs([]primitive.ObjectID{moduleID}, onlineAt)[moduleID.Hex()]; ok {
|
|
t.Fatal("module should be browsable at onlineAt")
|
|
}
|
|
if _, ok := snapshot.blockedOutsideSearchModuleIDs([]primitive.ObjectID{moduleID}, offlineAt)[moduleID.Hex()]; !ok {
|
|
t.Fatal("module should be blocked at offlineAt")
|
|
}
|
|
|
|
if got := len(snapshot.activeAt(onlineAt.Add(-time.Nanosecond))); got != 0 {
|
|
t.Fatalf("active before onlineAt = %d, want 0", got)
|
|
}
|
|
if got := len(snapshot.activeAt(onlineAt)); got != 1 {
|
|
t.Fatalf("active at onlineAt = %d, want 1", got)
|
|
}
|
|
if got := len(snapshot.activeAt(offlineAt)); got != 0 {
|
|
t.Fatalf("active at offlineAt = %d, want 0", got)
|
|
}
|
|
if got := calls.Load(); got != 1 {
|
|
t.Fatalf("loader calls = %d, want 1", got)
|
|
}
|
|
}
|
|
|
|
func TestModuleSnapshotExcludedSearchModuleIDs(t *testing.T) {
|
|
excludedID := primitive.NewObjectID()
|
|
includedID := primitive.NewObjectID()
|
|
snapshot := newModuleSnapshot([]ModuleConf{
|
|
{ID: excludedID, ExcludeSearch: true},
|
|
{ID: includedID, ExcludeSearch: false},
|
|
})
|
|
|
|
ids := snapshot.excludedSearchModuleIDs()
|
|
assertStringSetContains(t, ids, excludedID.Hex(), true)
|
|
assertStringSetContains(t, ids, includedID.Hex(), false)
|
|
}
|
|
|
|
func TestModuleSnapshotAccessorsReturnDeepCopies(t *testing.T) {
|
|
moduleID := primitive.NewObjectID()
|
|
onlineAt := time.Date(2026, 7, 31, 8, 0, 0, 0, time.UTC)
|
|
snapshot := newModuleSnapshot([]ModuleConf{{
|
|
ID: moduleID,
|
|
SubModuleName: "original",
|
|
OnlineAt: &onlineAt,
|
|
HaiJiaoStyle: HaiJiaoStyle{
|
|
SortRules: []SortItem{{Val: commod.MostHot, Name: "original-rule"}},
|
|
},
|
|
}})
|
|
|
|
first := snapshot.all()
|
|
first[0].SubModuleName = "changed"
|
|
*first[0].OnlineAt = first[0].OnlineAt.Add(time.Hour)
|
|
first[0].HaiJiaoStyle.SortRules[0].Name = "changed-rule"
|
|
|
|
second := snapshot.all()
|
|
if second[0].SubModuleName != "original" {
|
|
t.Fatalf("shared scalar was mutated: %+v", second[0])
|
|
}
|
|
if !second[0].OnlineAt.Equal(onlineAt) {
|
|
t.Fatalf("shared schedule was mutated: %v", second[0].OnlineAt)
|
|
}
|
|
if second[0].HaiJiaoStyle.SortRules[0].Name != "original-rule" {
|
|
t.Fatalf("shared sort rules were mutated: %+v", second[0].HaiJiaoStyle.SortRules)
|
|
}
|
|
}
|
|
|
|
func TestGetByIDPreservesNoDocumentsContract(t *testing.T) {
|
|
original := moduleMetadataCache
|
|
moduleMetadataCache = newModuleSnapshotCache(
|
|
func() ([]ModuleConf, error) { return []ModuleConf{}, nil },
|
|
time.Now,
|
|
time.Hour,
|
|
time.Second,
|
|
)
|
|
t.Cleanup(func() { moduleMetadataCache = original })
|
|
|
|
if _, err := GetByID(primitive.NewObjectID()); !errors.Is(err, mongo.ErrNoDocuments) {
|
|
t.Fatalf("GetByID() error = %v, want mongo.ErrNoDocuments", err)
|
|
}
|
|
}
|
|
|
|
func assertCachedModuleName(
|
|
t *testing.T,
|
|
cache *moduleSnapshotCache,
|
|
moduleID primitive.ObjectID,
|
|
want string,
|
|
) {
|
|
t.Helper()
|
|
snapshot, err := cache.get()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
module, ok := snapshot.findByID(moduleID)
|
|
if !ok {
|
|
t.Fatalf("module %s not found", moduleID.Hex())
|
|
}
|
|
if module.SubModuleName != want {
|
|
t.Fatalf("module name = %q, want %q", module.SubModuleName, want)
|
|
}
|
|
}
|
|
|
|
func assertStringSetContains(t *testing.T, values []string, target string, want bool) {
|
|
t.Helper()
|
|
for _, value := range values {
|
|
if value == target {
|
|
if !want {
|
|
t.Fatalf("%q unexpectedly found in %v", target, values)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
if want {
|
|
t.Fatalf("%q not found in %v", target, values)
|
|
}
|
|
}
|