Skip to content

Commit 1563505

Browse files
committed
use synctest to test state expiry
1 parent 25f5131 commit 1563505

1 file changed

Lines changed: 374 additions & 0 deletions

File tree

internal/state/state_test.go

Lines changed: 374 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"log/slog"
66
"os"
77
"testing"
8+
"testing/synctest"
89
"time"
910

1011
lru "github.com/patrickmn/go-cache"
@@ -564,3 +565,376 @@ func testLogger() *slog.Logger {
564565
Level: slog.LevelError, // Only show errors during tests
565566
}))
566567
}
568+
569+
// TestSetStateWithExpiration_Synctest uses synctest to verify expiration logic
570+
func TestSetStateWithExpiration_Synctest(t *testing.T) {
571+
synctest.Test(t, func(t *testing.T) {
572+
// Initial time: midnight UTC 2000-01-01
573+
start := time.Now()
574+
575+
// Create state with entries expiring at different times
576+
state := State{
577+
Rate: map[string]CacheEntry{
578+
"192.168.0.0": {
579+
Value: uint(10),
580+
Expiration: start.Add(5 * time.Second).UnixNano(), // expires in 5s
581+
},
582+
"10.0.0.0": {
583+
Value: uint(5),
584+
Expiration: start.Add(10 * time.Second).UnixNano(), // expires in 10s
585+
},
586+
},
587+
Bots: map[string]CacheEntry{
588+
"1.2.3.4": {
589+
Value: true,
590+
Expiration: start.Add(3 * time.Second).UnixNano(), // expires in 3s
591+
},
592+
},
593+
Verified: map[string]CacheEntry{
594+
"9.9.9.9": {
595+
Value: true,
596+
Expiration: 0, // never expires
597+
},
598+
},
599+
}
600+
601+
// Create empty caches (no cleanup interval to avoid background goroutines)
602+
rateCache := lru.New(1*time.Hour, lru.NoExpiration)
603+
botCache := lru.New(1*time.Hour, lru.NoExpiration)
604+
verifiedCache := lru.New(1*time.Hour, lru.NoExpiration)
605+
606+
// Load state
607+
SetState(state, rateCache, botCache, verifiedCache)
608+
609+
// Verify all entries are loaded
610+
if rateCache.ItemCount() != 2 {
611+
t.Errorf("Expected 2 rate entries, got %d", rateCache.ItemCount())
612+
}
613+
if botCache.ItemCount() != 1 {
614+
t.Errorf("Expected 1 bot entry, got %d", botCache.ItemCount())
615+
}
616+
if verifiedCache.ItemCount() != 1 {
617+
t.Errorf("Expected 1 verified entry, got %d", verifiedCache.ItemCount())
618+
}
619+
620+
// Advance time by 4 seconds (bot entry should expire, rate entries still valid)
621+
time.Sleep(4 * time.Second)
622+
synctest.Wait()
623+
624+
// Bot cache should be empty (expired at 3s)
625+
if _, found := botCache.Get("1.2.3.4"); found {
626+
t.Error("Bot entry should have expired after 3 seconds")
627+
}
628+
629+
// Rate entries should still be present
630+
if _, found := rateCache.Get("192.168.0.0"); !found {
631+
t.Error("Rate entry 192.168.0.0 should not expire until 5 seconds")
632+
}
633+
if _, found := rateCache.Get("10.0.0.0"); !found {
634+
t.Error("Rate entry 10.0.0.0 should not expire until 10 seconds")
635+
}
636+
637+
// Advance time by 2 more seconds (total 6s, first rate entry should expire)
638+
time.Sleep(2 * time.Second)
639+
synctest.Wait()
640+
641+
// First rate entry should be expired
642+
if _, found := rateCache.Get("192.168.0.0"); found {
643+
t.Error("Rate entry 192.168.0.0 should have expired after 5 seconds")
644+
}
645+
646+
// Second rate entry should still be present
647+
if _, found := rateCache.Get("10.0.0.0"); !found {
648+
t.Error("Rate entry 10.0.0.0 should not expire until 10 seconds")
649+
}
650+
651+
// Verified entry with no expiration should still be present
652+
if _, found := verifiedCache.Get("9.9.9.9"); !found {
653+
t.Error("Verified entry with no expiration should never expire")
654+
}
655+
656+
// Advance time by 5 more seconds (total 11s, all time-based entries expired)
657+
time.Sleep(5 * time.Second)
658+
synctest.Wait()
659+
660+
// All time-based entries should be expired
661+
if _, found := rateCache.Get("10.0.0.0"); found {
662+
t.Error("Rate entry 10.0.0.0 should have expired after 10 seconds")
663+
}
664+
665+
// Only the never-expiring verified entry should remain
666+
if _, found := verifiedCache.Get("9.9.9.9"); !found {
667+
t.Error("Verified entry with no expiration should still be present after 11 seconds")
668+
}
669+
})
670+
}
671+
672+
// TestReconcileStateWithExpiration_Synctest tests reconciliation with time control
673+
func TestReconcileStateWithExpiration_Synctest(t *testing.T) {
674+
synctest.Test(t, func(t *testing.T) {
675+
start := time.Now()
676+
677+
// Create file state with entries expiring at different times
678+
fileState := State{
679+
Rate: map[string]CacheEntry{
680+
"192.168.0.0": {
681+
Value: uint(15),
682+
Expiration: start.Add(10 * time.Second).UnixNano(), // newer expiration
683+
},
684+
"10.0.0.0": {
685+
Value: uint(3),
686+
Expiration: start.Add(5 * time.Second).UnixNano(), // older expiration
687+
},
688+
},
689+
}
690+
691+
// Create memory caches with overlapping data (no cleanup interval to avoid background goroutines)
692+
rateCache := lru.New(1*time.Hour, lru.NoExpiration)
693+
botCache := lru.New(1*time.Hour, lru.NoExpiration)
694+
verifiedCache := lru.New(1*time.Hour, lru.NoExpiration)
695+
696+
// Memory entry with older expiration (should be replaced)
697+
rateCache.Set("192.168.0.0", uint(10), 5*time.Second)
698+
// Memory entry with newer expiration (should be kept)
699+
rateCache.Set("10.0.0.0", uint(5), 10*time.Second)
700+
701+
// Reconcile
702+
ReconcileState(fileState, rateCache, botCache, verifiedCache)
703+
704+
// 192.168.0.0 should have file's value (newer expiration)
705+
if v, ok := rateCache.Get("192.168.0.0"); !ok || v.(uint) != 15 {
706+
t.Errorf("Expected rate 15 for 192.168.0.0, got %v", v)
707+
}
708+
709+
// 10.0.0.0 should have memory's value (newer expiration)
710+
if v, ok := rateCache.Get("10.0.0.0"); !ok || v.(uint) != 5 {
711+
t.Errorf("Expected rate 5 for 10.0.0.0 (memory kept), got %v", v)
712+
}
713+
714+
// Advance time by 6 seconds
715+
time.Sleep(6 * time.Second)
716+
synctest.Wait()
717+
718+
// Both entries should still be present (both have 10s expiration from reconciliation)
719+
// - 192.168.0.0 has file's value (15) with 10s expiration
720+
// - 10.0.0.0 has memory's value (5) with 10s expiration
721+
if _, found := rateCache.Get("10.0.0.0"); !found {
722+
t.Error("Entry 10.0.0.0 should not expire until 10 seconds (memory had newer expiration)")
723+
}
724+
725+
if _, found := rateCache.Get("192.168.0.0"); !found {
726+
t.Error("Entry 192.168.0.0 should not expire until 10 seconds (file had newer expiration)")
727+
}
728+
729+
// Advance time by 5 more seconds (total 11s)
730+
time.Sleep(5 * time.Second)
731+
synctest.Wait()
732+
733+
// All entries should be expired (verify by trying to get them)
734+
if _, found := rateCache.Get("192.168.0.0"); found {
735+
t.Error("Entry 192.168.0.0 should have expired after 10 seconds")
736+
}
737+
// Manually trigger cleanup since we're not using automatic janitor
738+
rateCache.DeleteExpired()
739+
if rateCache.ItemCount() != 0 {
740+
t.Errorf("Expected all entries expired, got %d entries", rateCache.ItemCount())
741+
}
742+
})
743+
}
744+
745+
// TestSaveAndLoadStateWithExpiration_Synctest tests full save/load cycle with time control
746+
func TestSaveAndLoadStateWithExpiration_Synctest(t *testing.T) {
747+
synctest.Test(t, func(t *testing.T) {
748+
tmpFile := t.TempDir() + "/state.json"
749+
750+
// Create caches with entries expiring at different times (no cleanup interval to avoid background goroutines)
751+
rateCache1 := lru.New(1*time.Hour, lru.NoExpiration)
752+
botCache1 := lru.New(1*time.Hour, lru.NoExpiration)
753+
verifiedCache1 := lru.New(1*time.Hour, lru.NoExpiration)
754+
755+
rateCache1.Set("192.168.0.0", uint(10), 5*time.Second)
756+
rateCache1.Set("10.0.0.0", uint(5), 10*time.Second)
757+
botCache1.Set("1.2.3.4", true, 3*time.Second)
758+
verifiedCache1.Set("9.9.9.9", true, lru.NoExpiration)
759+
760+
// Save state
761+
_, _, _, _, _, _, err := SaveStateToFile(
762+
tmpFile,
763+
false,
764+
rateCache1,
765+
botCache1,
766+
verifiedCache1,
767+
testLogger(),
768+
)
769+
if err != nil {
770+
t.Fatalf("SaveStateToFile failed: %v", err)
771+
}
772+
773+
// Advance time by 4 seconds (bot expires, rates still valid)
774+
time.Sleep(4 * time.Second)
775+
synctest.Wait()
776+
777+
// Load into new caches (no cleanup interval to avoid background goroutines)
778+
rateCache2 := lru.New(1*time.Hour, lru.NoExpiration)
779+
botCache2 := lru.New(1*time.Hour, lru.NoExpiration)
780+
verifiedCache2 := lru.New(1*time.Hour, lru.NoExpiration)
781+
782+
err = LoadStateFromFile(tmpFile, rateCache2, botCache2, verifiedCache2)
783+
if err != nil {
784+
t.Fatalf("LoadStateFromFile failed: %v", err)
785+
}
786+
787+
// Bot entry should be filtered out (expired 1 second ago)
788+
if botCache2.ItemCount() != 0 {
789+
t.Errorf("Expected 0 bot entries (expired), got %d", botCache2.ItemCount())
790+
}
791+
792+
// First rate entry should be loaded (expires at 5s, we're at 4s)
793+
if _, found := rateCache2.Get("192.168.0.0"); !found {
794+
t.Error("Rate entry 192.168.0.0 should be loaded (not yet expired)")
795+
}
796+
797+
// Second rate entry should be loaded (expires at 10s, we're at 4s)
798+
if _, found := rateCache2.Get("10.0.0.0"); !found {
799+
t.Error("Rate entry 10.0.0.0 should be loaded (not yet expired)")
800+
}
801+
802+
// Verified entry should be loaded (no expiration)
803+
if _, found := verifiedCache2.Get("9.9.9.9"); !found {
804+
t.Error("Verified entry should be loaded (no expiration)")
805+
}
806+
807+
// Advance time by 2 more seconds (total 6s, first rate entry expires)
808+
time.Sleep(2 * time.Second)
809+
synctest.Wait()
810+
811+
// First rate entry should be expired
812+
if _, found := rateCache2.Get("192.168.0.0"); found {
813+
t.Error("Rate entry 192.168.0.0 should have expired")
814+
}
815+
816+
// Second rate entry should still exist
817+
if _, found := rateCache2.Get("10.0.0.0"); !found {
818+
t.Error("Rate entry 10.0.0.0 should still be present")
819+
}
820+
})
821+
}
822+
823+
// TestReconcilePreservesNewerData_Synctest verifies reconciliation keeps fresher data
824+
func TestReconcilePreservesNewerData_Synctest(t *testing.T) {
825+
synctest.Test(t, func(t *testing.T) {
826+
tmpFile := t.TempDir() + "/state.json"
827+
828+
// Create initial state file with data expiring in 5 seconds (no cleanup interval to avoid background goroutines)
829+
initialCache := lru.New(1*time.Hour, lru.NoExpiration)
830+
initialCache.Set("192.168.0.0", uint(100), 5*time.Second)
831+
832+
_, _, _, _, _, _, err := SaveStateToFile(
833+
tmpFile,
834+
false,
835+
initialCache,
836+
lru.New(1*time.Hour, lru.NoExpiration),
837+
lru.New(1*time.Hour, lru.NoExpiration),
838+
testLogger(),
839+
)
840+
if err != nil {
841+
t.Fatalf("Initial save failed: %v", err)
842+
}
843+
844+
// Advance time by 2 seconds
845+
time.Sleep(2 * time.Second)
846+
synctest.Wait()
847+
848+
// Create new in-memory data with expiration in 10 seconds from original start
849+
// This represents fresher data (no cleanup interval to avoid background goroutines)
850+
newCache := lru.New(1*time.Hour, lru.NoExpiration)
851+
newCache.Set("192.168.0.0", uint(200), 8*time.Second) // expires at start+10s
852+
853+
// Save with reconciliation enabled
854+
_, _, _, _, _, _, err = SaveStateToFile(
855+
tmpFile,
856+
true, // reconcile
857+
newCache,
858+
lru.New(1*time.Hour, lru.NoExpiration),
859+
lru.New(1*time.Hour, lru.NoExpiration),
860+
testLogger(),
861+
)
862+
if err != nil {
863+
t.Fatalf("Reconciled save failed: %v", err)
864+
}
865+
866+
// Load back and verify we got the newer value (no cleanup interval to avoid background goroutines)
867+
loadedCache := lru.New(1*time.Hour, lru.NoExpiration)
868+
err = LoadStateFromFile(
869+
tmpFile,
870+
loadedCache,
871+
lru.New(1*time.Hour, lru.NoExpiration),
872+
lru.New(1*time.Hour, lru.NoExpiration),
873+
)
874+
if err != nil {
875+
t.Fatalf("Load failed: %v", err)
876+
}
877+
878+
// Should have the newer value (200 with later expiration)
879+
if v, found := loadedCache.Get("192.168.0.0"); !found || v.(uint) != 200 {
880+
t.Errorf("Expected value 200 (newer data), got %v (found=%v)", v, found)
881+
}
882+
883+
// Advance time by 4 more seconds (total 6s from start)
884+
// Old data would have expired at 5s, new data expires at 10s
885+
time.Sleep(4 * time.Second)
886+
synctest.Wait()
887+
888+
// New data should still be valid
889+
if _, found := loadedCache.Get("192.168.0.0"); !found {
890+
t.Error("Newer data should still be valid (expires at 10s, we're at 6s)")
891+
}
892+
893+
// Advance time by 5 more seconds (total 11s from start)
894+
time.Sleep(5 * time.Second)
895+
synctest.Wait()
896+
897+
// Now the newer data should also be expired
898+
if _, found := loadedCache.Get("192.168.0.0"); found {
899+
t.Error("Newer data should have expired after 10 seconds")
900+
}
901+
})
902+
}
903+
904+
// TestCacheCleanupInterval_Synctest verifies go-cache cleanup runs on schedule
905+
// NOTE: This test is skipped because it tests the janitor goroutine which is incompatible with synctest
906+
func TestCacheCleanupInterval_Synctest(t *testing.T) {
907+
t.Skip("Skipping test that requires janitor goroutine (incompatible with synctest)")
908+
synctest.Test(t, func(t *testing.T) {
909+
// Create cache with 1 minute cleanup interval
910+
cleanupInterval := 1 * time.Minute
911+
cache := lru.New(5*time.Second, cleanupInterval)
912+
913+
// Add entry that expires in 3 seconds
914+
cache.Set("test-key", uint(42), 3*time.Second)
915+
916+
// Verify entry exists
917+
if _, found := cache.Get("test-key"); !found {
918+
t.Fatal("Entry should exist immediately after Set")
919+
}
920+
921+
// Advance time by 4 seconds (entry expired but cleanup hasn't run)
922+
time.Sleep(4 * time.Second)
923+
synctest.Wait()
924+
925+
// Entry is expired but might still be in cache (cleanup hasn't run yet)
926+
// The Get should return false because go-cache checks expiration on Get
927+
if _, found := cache.Get("test-key"); found {
928+
t.Error("Entry should be expired after 3 seconds")
929+
}
930+
931+
// Advance time to trigger cleanup (cleanup runs every 1 minute)
932+
time.Sleep(57 * time.Second) // Total 61 seconds, cleanup should have run
933+
synctest.Wait()
934+
935+
// Entry should definitely be cleaned up now
936+
if cache.ItemCount() != 0 {
937+
t.Errorf("Cache should be empty after cleanup, got %d items", cache.ItemCount())
938+
}
939+
})
940+
}

0 commit comments

Comments
 (0)