packages feed

keter-rate-limiting-plugin-0.2.0.0: test/Main.hs

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE NumericUnderscores #-}

{-|
Module      : Main
Description : Main entry point for the rate limiter test suite.
Copyright   : (c) 2025 Oleksandr Zhabenko
License     : MIT
Maintainer  : oleksandr.zhabenko@yahoo.com
Stability   : stable
Portability : portable

This module aggregates all test suites for the Keter Rate Limiter library
and provides the main execution entry point. It includes general rate limiter tests,
IP zone functionality tests, and specific cache API tests.
-}
module Main (
  -- * Main Entry
  main
, mainTests
  -- * Test Suites
, rateLimiterTests
, ipZoneTests
, cacheApiTests
  -- * Test Cases
  -- ** General Rate Limiter Tests
, testFixedWindow
, testSlidingWindow
, testTokenBucket
, testLeakyBucket
, testPathSpecificThrottle
, testMultipleThrottles
, testOriginalResetAfterPeriod
, testTimeBasedReset
  -- ** IP Zone Tests
, testIPZoneIsolation
, testIPZoneDefaultFallback
, testIPZoneCacheResetAll
  -- ** Cache API Tests
, testIncStoreWithZone
, testIncStoreManual
, testReadWriteCacheWithZone
, testReadWriteCacheManual
  -- * Helpers
, mainTestGetRequestIPZone
, getRequestPath
, makeRequest
, mockSockAddr
, mockApp
, getResponseBody
, isRateLimited
, executeRequests
) where

import Control.Concurrent.STM (atomically, readTVar)
import Control.Concurrent (threadDelay)
import Control.Monad (when)
import Data.Maybe (isJust)
--import Data.IORef (readIORef)
import Control.Concurrent.STM (readTVarIO)
import Data.Cache (purgeExpired)
import Test.Tasty (TestTree, defaultMain, testGroup, after, DependencyType(..))
import Test.Tasty.HUnit (testCase, assertEqual, assertFailure)
import qualified Data.HashMap.Strict as HashMap
import Data.Text (Text)
import qualified Data.Text as Text
import qualified Data.Text.Encoding as TE
import Network.Wai (Request, Response, Application, requestMethod, rawPathInfo, requestHeaderHost, remoteHost, responseLBS, requestHeaders, defaultRequest)
import Network.HTTP.Types (methodGet)
import Network.Socket (SockAddr(..))
import Keter.RateLimiter.Cache ( Cache(..), InMemoryStore(..), Algorithm(..), makeCacheKey, readCache, createInMemoryStore, newCache, incrementCache, writeCache, deleteCache )
import Keter.RateLimiter.CacheWithZone ( incStoreWithZone, writeCacheWithZone, readCacheWithZone )
import Keter.RateLimiter.IPZones (ZoneSpecificCaches(..), IPZoneIdentifier, defaultIPZone)
import Keter.RateLimiter.WAI ( Env, ThrottleConfig(..), initConfig, addThrottle, instrument, envZoneCachesMap, cacheResetAll, IdentifierBy(..) )
import qualified Keter.RateLimiter.RequestUtils as RequestUtils
import System.IO.Unsafe (unsafePerformIO)

-- Import test modules with qualified names to avoid ambiguity
import qualified TinyLRUTests
import Keter.RateLimiter.Cache.PurgeTests
import qualified Keter.RateLimiter.IPZonesTests
import qualified Keter.RateLimiter.LeakyBucketStateTests
import qualified Keter.RateLimiter.LeakyBucketTests
import qualified Keter.RateLimiter.NotificationTests
import qualified Keter.RateLimiter.SlidingWindowTests
import qualified Keter.RateLimiter.TokenBucketTests
import qualified Keter.RateLimiter.WAITests
import qualified Keter.RateLimiter.TokenBucketStateTests

-- IPs and Zone Identifiers for testing
testIPZoneA, testIPZoneB :: IPZoneIdentifier
testIPZoneA = Text.pack "testZoneA"
testIPZoneB = Text.pack "testZoneB"
ipForZoneA, ipForZoneB, ipForDefaultZone, genericTestIP, genericTestIP2 :: Text
ipForZoneA = "10.0.0.1"
ipForZoneB = "20.0.0.1"
ipForDefaultZone = "30.0.0.1"
genericTestIP = "192.168.1.1"
genericTestIP2 = "192.168.1.2"

-- | A test-specific helper to determine the 'IPZoneIdentifier' for a given request.
-- This function maps specific IP addresses to predefined zones for testing purposes.
-- It uses 'unsafePerformIO' for simplicity within the test context.
--
-- Note: Uses 'RequestUtils.getClientIP' which respects 'x-real-ip' and 'x-forwarded-for' headers.
mainTestGetRequestIPZone :: Request -> IPZoneIdentifier
mainTestGetRequestIPZone req =
  let ip = unsafePerformIO $ RequestUtils.byIP req
  in case ip of
    Just val
      | val == ipForZoneA -> testIPZoneA
      | val == ipForZoneB -> testIPZoneB
      | val == genericTestIP -> defaultIPZone
      | val == genericTestIP2 -> defaultIPZone
      | val == ipForDefaultZone -> defaultIPZone
    _ -> defaultIPZone

-- | A simple helper to extract the request path as 'Text'.
getRequestPath :: Request -> Text
getRequestPath req = Text.pack $ show $ rawPathInfo req

-- | Creates a mock 'Request' for testing purposes.
-- The request is configured with a specified IP address (via 'x-real-ip' header) and path.
makeRequest
  :: Text -- ^ The client IP address.
  -> Text -- ^ The request path.
  -> Request
makeRequest ip path = defaultRequest
  { requestMethod = methodGet
  , rawPathInfo = TE.encodeUtf8 path
  , requestHeaderHost = Just "example.com"
  , remoteHost = mockSockAddr ip
  , requestHeaders = [("x-real-ip", TE.encodeUtf8 ip)]
  }

-- | Creates a mock 'SockAddr' for a given IP address.
-- This is a simplified implementation for testing and always returns 'SockAddrInet'.
mockSockAddr
  :: Text -- ^ The IP address (not used in this simplified version).
  -> SockAddr
mockSockAddr _ = SockAddrInet 80 0 -- Simplified for testing

-- | A mock WAI 'Application' that always returns a 200 OK response with a "Success" body.
mockApp :: Application
mockApp _ respond = respond $ responseLBS
  (toEnum 200)
  [("Content-Type", "text/plain")]
  "Success"

-- | A simplified helper to extract the response body.
-- In this test suite, it always returns "Success".
getResponseBody
  :: Response
  -> IO Text
getResponseBody _ = return "Success" -- Simplified

-- | A simplified helper to check if a response was rate-limited.
-- In this test suite, it is not implemented and always returns 'False'.
isRateLimited
  :: Response
  -> Bool
isRateLimited _ = False -- Simplified

-- | Executes a series of requests against the rate-limiting middleware and asserts that the responses match expectations.
executeRequests
  :: Env        -- ^ The rate limiter environment.
  -> [Request]  -- ^ A list of requests to execute.
  -> [Text]     -- ^ A list of expected response bodies ("Success" or "Too Many Requests").
  -> IO ()
executeRequests env requests expectedResponses = do
  actualResponses <- mapM (processRequest env) requests
  assertEqual "Responses should match expected" expectedResponses actualResponses
  where
    processRequest :: Env -> Request -> IO Text
    processRequest testEnv req = do
      blocked <- instrument testEnv req
      return $ if blocked then "Too Many Requests" else "Success"

-- | The main entry point for running the test suite.
-- It aggregates all tests and runs them using Tasty.
main :: IO ()
main = defaultMain $ after AllSucceed "All tests passed" mainTests

-- | The root 'TestTree' that combines all test groups from the library.
mainTests :: TestTree
mainTests = testGroup "Rate Limiter and IP Zones Tests"
  [ TinyLRUTests.tests
  , Keter.RateLimiter.NotificationTests.tests
  , testBackgroundPurge
  , rateLimiterTests
  , ipZoneTests
  , cacheApiTests
  , Keter.RateLimiter.IPZonesTests.tests
  , Keter.RateLimiter.LeakyBucketStateTests.tests
  , Keter.RateLimiter.TokenBucketStateTests.tests
  , Keter.RateLimiter.LeakyBucketTests.tests
  , Keter.RateLimiter.SlidingWindowTests.tests
  , Keter.RateLimiter.TokenBucketTests.tests
  , Keter.RateLimiter.WAITests.tests
  ]

-- | Tests for general rate-limiting functionality using the default IP zone.
rateLimiterTests :: TestTree
rateLimiterTests = testGroup "General Rate Limiter Tests (with Default IP Zone)"
  [ testCase "Fixed Window Rate Limiting" testFixedWindow
  , testCase "Sliding Window Rate Limiting" testSlidingWindow
  , testCase "Token Bucket Rate Limiting" testTokenBucket
  , testCase "Leaky Bucket Rate Limiting" testLeakyBucket
  , testCase "Path-specific Rate Limiting" testPathSpecificThrottle
  , testCase "Multiple Throttles" testMultipleThrottles
  , testCase "Original Reset After Period (New Env)" testOriginalResetAfterPeriod
  , testCase "Time-based Reset (Same Env)" testTimeBasedReset
  ]

-- | Tests the Fixed Window algorithm. It expects the first 3 requests to succeed and the next 2 to be blocked.
testFixedWindow :: IO ()
testFixedWindow = do
  env <- initConfig mainTestGetRequestIPZone
  let throttleConfig = ThrottleConfig
        { throttleLimit = 3
        , throttlePeriod = 10
        , throttleAlgorithm = FixedWindow
        , throttleIdentifierBy = IdIP
        , throttleTokenBucketTTL = Nothing
        }
  envWithThrottle <- addThrottle env "test-throttle" throttleConfig
  let requests = replicate 5 $ makeRequest genericTestIP "/test"
      expectedResponses = replicate 3 "Success" ++ replicate 2 "Too Many Requests"
  executeRequests envWithThrottle requests expectedResponses

-- | Tests the Sliding Window algorithm. It expects the first 3 requests to succeed and subsequent requests within the window to be blocked.
-- It also verifies that requests are allowed again after the window has passed.
testSlidingWindow :: IO ()
testSlidingWindow = do
  env <- initConfig mainTestGetRequestIPZone
  let throttleConfig = ThrottleConfig
        { throttleLimit = 3
        , throttlePeriod = 10
        , throttleAlgorithm = SlidingWindow
        , throttleIdentifierBy = IdIP
        , throttleTokenBucketTTL = Nothing
        }
  envWithThrottle <- addThrottle env "test-throttle" throttleConfig
  let requests = replicate 5 $ makeRequest genericTestIP "/test"
      expectedResponses = replicate 3 "Success" ++ replicate 2 "Too Many Requests"
  executeRequests envWithThrottle requests expectedResponses
  -- Test partial window overlap
  threadDelay (5_000_000) -- Wait 5s (half period)
  blocked <- instrument envWithThrottle (makeRequest genericTestIP "/test")
  assertEqual "Request in overlapping window should be blocked" True blocked
  threadDelay (6_500_000) -- Wait 5.5s (past period)
  blocked' <- instrument envWithThrottle (makeRequest genericTestIP "/test")
  assertEqual "Request after full period should succeed" False blocked'

-- | Tests the Token Bucket algorithm, including TTL-based bucket expiry and refill.
testTokenBucket :: IO ()
testTokenBucket = do
  let ttlSeconds = 3
  env <- initConfig mainTestGetRequestIPZone
  let throttleConfig = ThrottleConfig
        { throttleLimit = 3
        , throttlePeriod = 1
        , throttleAlgorithm = TokenBucket
        , throttleIdentifierBy = IdIP
        , throttleTokenBucketTTL = Just ttlSeconds
        }
  envWithThrottle <- addThrottle env "test-throttle" throttleConfig
  let requests = replicate 5 $ makeRequest genericTestIP "/test"
      expectedResponses = replicate 3 "Success" ++ replicate 2 "Too Many Requests"
  executeRequests envWithThrottle requests expectedResponses
  threadDelay ((ttlSeconds + 1) * 1_000_000)
  blocked <- instrument envWithThrottle (makeRequest genericTestIP "/test")
  assertEqual "Request should succeed after TTL expiry and refill" False blocked
  b1 <- instrument envWithThrottle (makeRequest genericTestIP "/test")
  b2 <- instrument envWithThrottle (makeRequest genericTestIP "/test")
  b3 <- instrument envWithThrottle (makeRequest genericTestIP "/test")
  assertEqual "Next requests after refill" [False, False, True] [b1, b2, b3]

-- | Tests the Leaky Bucket algorithm, verifying burst tolerance and the steady leak rate.
testLeakyBucket :: IO ()
testLeakyBucket = do
  env <- initConfig mainTestGetRequestIPZone
  let throttleConfig = ThrottleConfig
        { throttleLimit = 3
        , throttlePeriod = 10
        , throttleAlgorithm = LeakyBucket
        , throttleIdentifierBy = IdIP
        , throttleTokenBucketTTL = Nothing
        }
  envWithThrottle <- addThrottle env "test-throttle" throttleConfig
  let req = makeRequest genericTestIP "/test"
  -- Test burst behavior
  blocked1 <- instrument envWithThrottle req
  blocked2 <- instrument envWithThrottle req
  blocked3 <- instrument envWithThrottle req
  assertEqual "First 3 requests succeed" [False, False, False] [blocked1, blocked2, blocked3]
  blocked4 <- instrument envWithThrottle req
  assertEqual "Fourth request blocked" True blocked4
  -- Test steady leak rate (1 token every ~3.33s for limit=3, period=10)
  threadDelay (3_500_000) -- Wait ~3.5s for 1 token to leak
  blocked5 <- instrument envWithThrottle req
  assertEqual "Request succeeds after leak" False blocked5
  -- Verify subsequent request is blocked
  blocked6 <- instrument envWithThrottle req
  assertEqual "Next request blocked" True blocked6

-- | Tests that rate limiting can be applied to specific request paths.
-- In this case, only requests to "/login" are throttled.
testPathSpecificThrottle :: IO ()
testPathSpecificThrottle = do
  env <- initConfig mainTestGetRequestIPZone
  let throttleConfig = ThrottleConfig
        { throttleLimit = 2
        , throttlePeriod = 10
        , throttleAlgorithm = FixedWindow
        , throttleIdentifierBy = IdIPAndPath
        , throttleTokenBucketTTL = Nothing
        }
  envWithThrottle <- addThrottle env "login-throttle" throttleConfig
  let loginRequests = replicate 3 $ makeRequest genericTestIP "/login"
      homeRequests = replicate 3 $ makeRequest genericTestIP "/home"
      expectedLoginResponses = replicate 2 "Success" ++ ["Too Many Requests"]
      expectedHomeResponses = replicate 2 "Success" ++ ["Too Many Requests"]
  executeRequests envWithThrottle loginRequests expectedLoginResponses
  executeRequests envWithThrottle homeRequests expectedHomeResponses

-- | Tests the application of multiple, independent throttles.
-- An IP-based throttle and a path-specific throttle are applied simultaneously.
testMultipleThrottles :: IO ()
testMultipleThrottles = do
  env <- initConfig mainTestGetRequestIPZone
  let ipThrottleConfig = ThrottleConfig
        { throttleLimit = 5
        , throttlePeriod = 10
        , throttleAlgorithm = FixedWindow
        , throttleIdentifierBy = IdIP
        , throttleTokenBucketTTL = Nothing
        }
      loginThrottleConfig = ThrottleConfig
        { throttleLimit = 2
        , throttlePeriod = 10
        , throttleAlgorithm = SlidingWindow
        , throttleIdentifierBy = IdIPAndPath
        , throttleTokenBucketTTL = Nothing
        }
  envWithIpThrottle <- addThrottle env "ip-throttle" ipThrottleConfig
  envWithBothThrottles <- addThrottle envWithIpThrottle "login-throttle" loginThrottleConfig
  let requests = [ makeRequest genericTestIP "/login"
                 , makeRequest genericTestIP "/login"
                 , makeRequest genericTestIP "/login" -- Blocked by login throttle
                 , makeRequest genericTestIP "/home"
                 , makeRequest genericTestIP "/about"
                 , makeRequest genericTestIP "/contact" -- Blocked by IP throttle (6th req total)
                 ]
      expectedResponses = [ "Success", "Success", "Too Many Requests"
                         , "Success", "Success", "Too Many Requests"
                         ]
  executeRequests envWithBothThrottles requests expectedResponses

-- | Verifies that creating a new environment effectively resets rate-limiting state.
testOriginalResetAfterPeriod :: IO ()
testOriginalResetAfterPeriod = do
  env1 <- initConfig mainTestGetRequestIPZone
  let throttleConfig = ThrottleConfig
        { throttleLimit = 2
        , throttlePeriod = 1
        , throttleAlgorithm = FixedWindow
        , throttleIdentifierBy = IdIP
        , throttleTokenBucketTTL = Nothing
        }
  envWithThrottle1 <- addThrottle env1 "test-throttle" throttleConfig
  let requests1 = replicate 3 $ makeRequest genericTestIP "/test"
      expectedResponses1 = replicate 2 "Success" ++ ["Too Many Requests"]
  executeRequests envWithThrottle1 requests1 expectedResponses1
  threadDelay (1_100_000)
  env2 <- initConfig mainTestGetRequestIPZone
  envWithThrottle2 <- addThrottle env2 "test-throttle" throttleConfig
  let requests2 = replicate 3 $ makeRequest genericTestIP2 "/test"
      expectedResponses2 = replicate 2 "Success" ++ ["Too Many Requests"]
  executeRequests envWithThrottle2 requests2 expectedResponses2

-- | Verifies that counters for Fixed Window algorithm expire correctly over time within the same environment.
testTimeBasedReset :: IO ()
testTimeBasedReset = do
  let periodSec = 1
      limit = 1
  env <- initConfig mainTestGetRequestIPZone
  let throttleConfig = ThrottleConfig
        { throttleLimit = limit
        , throttlePeriod = periodSec
        , throttleAlgorithm = FixedWindow
        , throttleIdentifierBy = IdIP
        , throttleTokenBucketTTL = Nothing
        }
  envWithThrottle <- addThrottle env "reset-throttle" throttleConfig
  let req = makeRequest ipForZoneA "/reset_test"
  -- First request
  blocked1 <- instrument envWithThrottle req
  assertEqual "First request to zone A should succeed" False blocked1
  -- Second request (should be blocked)
  blocked2 <- instrument envWithThrottle req
  assertEqual "Second request to zone A should be blocked" True blocked2
  -- Wait for period + buffer
  threadDelay 1_500_000 -- 1.5 seconds to ensure expiration
  -- Debug cache state
  cachesMap <- readTVarIO (envZoneCachesMap envWithThrottle)
  zoneCaches <- case HashMap.lookup testIPZoneA cachesMap of
    Just caches -> return caches
    Nothing -> assertFailure "Zone caches not found" >> return undefined
  let cache :: Cache (InMemoryStore 'FixedWindow)
      cache = zscCounterCache zoneCaches
      key = makeCacheKey "reset-throttle" FixedWindow testIPZoneA (case unsafePerformIO $ RequestUtils.byIP req of Just ip -> ip; Nothing -> "")
  mVal <- readCache cache key :: IO (Maybe Int)
  when (isJust mVal) $ putStrLn $ "Cache value before third request: " ++ show mVal
  -- Try manually deleting if it exists
  when (isJust mVal) $ do
    putStrLn "Manually deleting expired key"
    deleteCache cache key
  -- Explicitly purge expired entries
  cache' <- atomically $ readTVar (case cacheStore cache of CounterStore ref -> ref)
  purgeExpired cache' -- Now in IO, not STM
  -- Third request (should succeed)
  blocked3 <- instrument envWithThrottle req
  assertEqual "Request after period to zone A should succeed" False blocked3

-- | Tests for verifying the functionality of IP Zones.
ipZoneTests :: TestTree
ipZoneTests = testGroup "IP Zone Functionality Tests"
  [ testCase "IP Zone Isolation" testIPZoneIsolation
  , testCase "IP Zone Default Fallback" testIPZoneDefaultFallback
  , testCase "Cache Reset All Across Zones" testIPZoneCacheResetAll
  ]

-- | Verifies that different IP zones are rate-limited independently.
testIPZoneIsolation :: IO ()
testIPZoneIsolation = do
  let limit = 1
      period = 10
  env <- initConfig mainTestGetRequestIPZone
  let throttleConfig = ThrottleConfig
        { throttleLimit = limit
        , throttlePeriod = period
        , throttleAlgorithm = FixedWindow
        , throttleIdentifierBy = IdIP
        , throttleTokenBucketTTL = Nothing
        }
  envWithThrottle <- addThrottle env "test-throttle" throttleConfig
  let reqA1 = makeRequest ipForZoneA "/test"
      reqB1 = makeRequest ipForZoneB "/test"
  b1 <- instrument envWithThrottle reqA1
  assertEqual "Zone A first request allowed" False b1
  b2 <- instrument envWithThrottle reqA1
  assertEqual "Zone A second request blocked" True b2
  b3 <- instrument envWithThrottle reqB1
  assertEqual "Zone B first request allowed" False b3

-- | Verifies that IPs not matching a specific zone fall back to the default zone and are tracked independently by their IP.
testIPZoneDefaultFallback :: IO ()
testIPZoneDefaultFallback = do
  env <- initConfig mainTestGetRequestIPZone
  let throttleConfig = ThrottleConfig
        { throttleLimit = 1
        , throttlePeriod = 10
        , throttleAlgorithm = FixedWindow
        , throttleIdentifierBy = IdIP
        , throttleTokenBucketTTL = Nothing
        }
  envWithThrottle <- addThrottle env "test-throttle" throttleConfig
  let reqDefault = makeRequest ipForDefaultZone "/test"
      reqGeneric = makeRequest genericTestIP "/test"
  b1 <- instrument envWithThrottle reqDefault
  assertEqual "Default zone first request allowed" False b1
  b2 <- instrument envWithThrottle reqGeneric
  assertEqual "Generic IP in default zone should also be allowed (independent counter)" False b2

-- | Verifies that 'cacheResetAll' clears the state for all defined IP zones.
testIPZoneCacheResetAll :: IO ()
testIPZoneCacheResetAll = do
  env <- initConfig mainTestGetRequestIPZone
  let throttleConfig = ThrottleConfig
        { throttleLimit = 1
        , throttlePeriod = 10
        , throttleAlgorithm = FixedWindow
        , throttleIdentifierBy = IdIP
        , throttleTokenBucketTTL = Nothing
        }
  envWithThrottle <- addThrottle env "test-throttle" throttleConfig
  let reqA = makeRequest ipForZoneA "/test"
      reqB = makeRequest ipForZoneB "/test"
  _ <- instrument envWithThrottle reqA
  _ <- instrument envWithThrottle reqB
  cacheResetAll envWithThrottle
  b1 <- instrument envWithThrottle reqA
  b2 <- instrument envWithThrottle reqB
  assertEqual "Zone A after reset" False b1
  assertEqual "Zone B after reset" False b2

-- | Tests for the cache API, including both the zone-aware wrappers and direct cache access.
cacheApiTests :: TestTree
cacheApiTests = testGroup "Cache API (wrappers and customisable)"
  [ testCase "incStoreWithZone wrapper increments independently" testIncStoreWithZone
  , testCase "incStore manual key increments independently" testIncStoreManual
  , testCase "readCacheWithZone and writeCacheWithZone" testReadWriteCacheWithZone
  , testCase "readCache and writeCache manual" testReadWriteCacheManual
  ]

-- | Tests that the 'incStoreWithZone' wrapper correctly increments a counter.
testIncStoreWithZone :: IO ()
testIncStoreWithZone = do
  store <- createInMemoryStore @'FixedWindow
  let cache :: Cache (InMemoryStore 'FixedWindow)
      cache = newCache FixedWindow store
      throttleName = "test-throttle"
      ipZone = "zoneX"
      userKey = "userX"
  v1 <- incStoreWithZone cache throttleName ipZone userKey 10 :: IO Int
  v2 <- incStoreWithZone cache throttleName ipZone userKey 10 :: IO Int
  assertEqual "First increment (wrapper)" 1 v1
  assertEqual "Second increment (wrapper)" 2 v2

-- | Tests that the lower-level 'incrementCache' function works correctly with a manually constructed key.
testIncStoreManual :: IO ()
testIncStoreManual = do
  store <- createInMemoryStore @'FixedWindow
  let cache :: Cache (InMemoryStore 'FixedWindow)
      cache = newCache FixedWindow store
      customKey = "zoneY:userY:extra"
  v1 <- incrementCache cache customKey 10 :: IO Int
  v2 <- incrementCache cache customKey 10 :: IO Int
  assertEqual "First increment (manual)" 1 v1
  assertEqual "Second increment (manual)" 2 v2

-- | Tests the 'writeCacheWithZone' and 'readCacheWithZone' wrapper functions.
testReadWriteCacheWithZone :: IO ()
testReadWriteCacheWithZone = do
  store <- createInMemoryStore @'FixedWindow
  let cache :: Cache (InMemoryStore 'FixedWindow)
      cache = newCache FixedWindow store
      throttleName = "test-throttle"
      ipZone = "zoneZ"
      userKey = "userZ"
      val = 42 :: Int
  writeCacheWithZone cache throttleName ipZone userKey val 10
  mVal <- readCacheWithZone cache throttleName ipZone userKey :: IO (Maybe Int)
  assertEqual "Read after write (wrapper)" (Just val) mVal

-- | Tests the lower-level 'writeCache' and 'readCache' functions with a manually constructed key.
testReadWriteCacheManual :: IO ()
testReadWriteCacheManual = do
  store <- createInMemoryStore @'FixedWindow
  let cache :: Cache (InMemoryStore 'FixedWindow)
      cache = newCache FixedWindow store
      customKey = "zoneW:userW:custom"
      val = 99 :: Int
  writeCache cache customKey val 10
  mVal <- readCache cache customKey :: IO (Maybe Int)
  assertEqual "Read after write (manual)" (Just val) mVal