|
5 | 5 | "log/slog" |
6 | 6 | "os" |
7 | 7 | "testing" |
| 8 | + "testing/synctest" |
8 | 9 | "time" |
9 | 10 |
|
10 | 11 | lru "github.com/patrickmn/go-cache" |
@@ -564,3 +565,376 @@ func testLogger() *slog.Logger { |
564 | 565 | Level: slog.LevelError, // Only show errors during tests |
565 | 566 | })) |
566 | 567 | } |
| 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