Files
rootandClaude Opus 5 8679200f41 Initial commit
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 13:57:10 +08:00

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)
}
}