packages feed

notion-client-0.8.0.0: tasty/RuntimeTests.hs

-- | Tests for the client runtime: configuration, errors and retries.
module RuntimeTests (tests) where

import Control.Exception (SomeException, fromException, throwIO, toException, try)
import Data.Aeson qualified as Aeson
import Data.ByteString qualified as BS
import Data.ByteString.Lazy.Char8 qualified as L8
import Data.IORef (modifyIORef', newIORef, readIORef)
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Time.Calendar (fromGregorian)
import Data.Time.Clock (UTCTime (..), secondsToDiffTime)
import FakeNotion
import Network.HTTP.Client qualified as HTTP
import Network.HTTP.Types qualified as HTTP
import Notion.V1
import Notion.V1.Client (applyTimeout)
import Notion.V1.Common (UUID (..))
import Notion.V1.Error
import Notion.V1.ListOf (IncompleteReason (..), ListOf (..), RequestStatus (..), RequestStatusType (..))
import Notion.V1.Retry (canRetry, parseRetryAfter, retryDelay, validateRequestPath)
import Notion.V1.Search (SearchRequest (..))
import Servant.Client (ClientEnv (..), ClientError (..))
import Test.Tasty
import Test.Tasty.HUnit

tests :: TestTree
tests =
  testGroup
    "Runtime"
    [ testGroup "Configuration" configurationTests,
      testGroup "Errors" errorTests,
      testGroup "Retry policy" retryPolicyTests,
      testGroup "Retry loop" retryLoopTests
    ]

-- | A bot user fixture (made-up name).
userJson :: L8.ByteString
userJson =
  "{\"object\":\"user\",\"id\":\"6f1c2b9e-4d3a-4e8f-9b7c-2a1d0e5f3c4b\",\"name\":\"Sato Kenji Bot\",\"avatar_url\":null,\"type\":\"bot\",\"bot\":{\"owner\":{\"type\":\"workspace\",\"workspace\":true},\"workspace_name\":\"Sato Kenji's Workspace\"}}"

-- | Run one call against a fake that answers with the user fixture.
singleRequest :: (ClientEnv -> Methods) -> IO Recorded
singleRequest mk = do
  (env, recorded) <- fakeClientEnv [jsonReply 200 userJson]
  _ <- retrieveMyUser (mk env)
  readIORef recorded >>= \case
    [r] -> pure r
    rs -> assertFailure ("expected one request, got " <> show (length rs))

configurationTests :: [TestTree]
configurationTests =
  [ testCase "makeMethods sends default Notion-Version, Bearer token and User-Agent" $ do
      r <- singleRequest (`makeMethods` "secret_tanaka")
      lookupRecordedHeader "Authorization" r @?= Just "Bearer secret_tanaka"
      lookupRecordedHeader "Notion-Version" r @?= Just "2026-03-11"
      case lookupRecordedHeader "User-Agent" r of
        Just ua -> assertBool ("User-Agent: " <> show ua) ("notion-client-haskell/" `BS.isPrefixOf` ua)
        Nothing -> assertFailure "no User-Agent header"
      path r @?= "/users/me",
    testCase "makeMethodsWithEnv honors a configured Notion-Version" $ do
      r <- singleRequest (\env -> makeMethodsWithEnv defaultClientConfig {notionVersion = "2025-09-03"} env "secret_tanaka")
      lookupRecordedHeader "Notion-Version" r @?= Just "2025-09-03",
    testCase "applyTimeout sets responseTimeout" $ do
      -- ResponseTimeout has no Eq instance; compare its Show output.
      show (HTTP.responseTimeout (applyTimeoutFor (Just 5)))
        @?= show (HTTP.responseTimeoutMicro 5000000)
      show (HTTP.responseTimeout (applyTimeoutFor Nothing))
        @?= show HTTP.responseTimeoutDefault,
    testCase "standardHeaders match what Methods sends" $ do
      r <- singleRequest (`makeMethods` "secret_tanaka")
      (env, _) <- fakeClientEnv []
      let context = requestContextFor legacyClientConfig env "secret_tanaka"
          sent = [(n, v) | (n, v) <- headers r, n `elem` ["Authorization", "Notion-Version", "User-Agent"]]
      standardHeaders context @?= sent
      contextBaseUrl context @?= fakeBaseUrl
  ]
  where
    applyTimeoutFor t = applyTimeout defaultClientConfig {timeout = t} HTTP.defaultRequest

allCodeStrings :: [Text]
allCodeStrings =
  [ "unauthorized",
    "restricted_resource",
    "object_not_found",
    "rate_limited",
    "invalid_json",
    "invalid_request_url",
    "invalid_request",
    "invalid_beta",
    "validation_error",
    "conflict_error",
    "internal_server_error",
    "service_overload",
    "service_unavailable",
    "gateway_timeout"
  ]

notFoundBody :: Bool -> L8.ByteString
notFoundBody withRequestId =
  "{\"object\":\"error\",\"status\":404,\"code\":\"object_not_found\",\"message\":\"Could not find page with ID: 5c6a2821-6bb1-4a7e-b6e1-c50111515c3d.\""
    <> (if withRequestId then ",\"request_id\":\"b1e0a4c2-7f3d-4e21-9a55-1c2d3e4f5a6b\"" else "")
    <> ",\"additional_data\":{\"integration_name\":\"Tanaka Hanako Integration\"}}"

notFoundHeaders :: [(HTTP.HeaderName, BS.ByteString)]
notFoundHeaders =
  [("Content-Type", "application/json"), ("x-notion-request-id", "req-header-1"), ("cf-ray", "8a1b2c3d4e5f-NRT")]

validationBody :: L8.ByteString
validationBody =
  "{\"object\":\"error\",\"status\":400,\"code\":\"validation_error\",\"message\":\"body failed validation.\"}"

errorTests :: [TestTree]
errorTests =
  [ testCase "APIErrorCode round-trips all 14 codes" $ do
      length allCodeStrings @?= 14
      mapM_ (\t -> apiErrorCodeText (parseAPIErrorCode t) @?= t) allCodeStrings
      assertBool "all 14 are known" (all (not . isUnknown . parseAPIErrorCode) allCodeStrings)
      parseAPIErrorCode "brand_new_code" @?= UnknownErrorCode "brand_new_code",
    testCase "buildRequestError parses a Notion error with headers" $
      case buildRequestError 404 notFoundHeaders (notFoundBody True) of
        Right NotionError {code, requestId, additionalData, response} -> do
          code @?= ObjectNotFound
          requestId @?= Just "b1e0a4c2-7f3d-4e21-9a55-1c2d3e4f5a6b"
          additionalData @?= Just (Aeson.object ["integration_name" Aeson..= ("Tanaka Hanako Integration" :: Text)])
          fmap rayId response @?= Just (Just "8a1b2c3d4e5f-NRT")
          fmap httpStatus response @?= Just 404
        Left e -> assertFailure ("expected NotionError, got " <> show e),
    testCase "request_id falls back to the x-notion-request-id header" $
      case buildRequestError 404 notFoundHeaders (notFoundBody False) of
        Right NotionError {requestId} -> requestId @?= Just "req-header-1"
        Left e -> assertFailure ("expected NotionError, got " <> show e),
    testCase "Cloudflare HTML 403 becomes UnknownHTTPResponseError" $
      case buildRequestError 403 [("content-type", "text/html"), ("cf-ray", "8a1b2c3d4e5f-NRT")] "<html>blocked</html>" of
        Left e ->
          unknownResponseMessage e
            @?= "Request to Notion API failed with status: 403. The response was returned by Notion's edge proxy before reaching the Notion API (content-type: text/html). This may mean the request was blocked by a network security rule. Cloudflare Ray ID: 8a1b2c3d4e5f-NRT. Include this ID when contacting Notion support."
        Right e -> assertFailure ("expected UnknownHTTPResponseError, got " <> show e),
    testCase "non-JSON 502 without cf-ray has the short message" $
      case buildRequestError 502 [("content-type", "text/plain")] "Bad Gateway" of
        Left e -> unknownResponseMessage e @?= "Request to Notion API failed with status: 502"
        Right e -> assertFailure ("expected UnknownHTTPResponseError, got " <> show e),
    testCase "timeouts become RequestTimeoutError" $ do
      let timeoutErr = ConnectionError (toException (HTTP.HttpExceptionRequest HTTP.defaultRequest HTTP.ResponseTimeout))
      fromExceptionOf (fromClientError timeoutErr) @?= Just RequestTimeoutError,
    testCase "makeMethods throws a typed NotionError" $ do
      (env, _) <- fakeClientEnv [FakeReply 400 [("Content-Type", "application/json"), ("x-notion-request-id", "req-9")] validationBody]
      result <- try @NotionError (retrieveMyUser (makeMethodsWithEnv defaultClientConfig {retryOptions = noRetries} env "secret_tanaka"))
      case result of
        Left NotionError {code, requestId, response} -> do
          code @?= ValidationError
          requestId @?= Just "req-9"
          fmap httpStatus response @?= Just 400
        Right _ -> assertFailure "expected a NotionError",
    testCase "ListOf decodes request_status" $ do
      let listWith extra =
            "{\"object\":\"list\",\"results\":[],\"next_cursor\":null,\"has_more\":false,\"type\":\"page_or_data_source\",\"page_or_data_source\":{}"
              <> extra
              <> "}"
          decodeStatus :: L8.ByteString -> Either String (Maybe RequestStatus)
          decodeStatus bytes = (\List {requestStatus} -> requestStatus) <$> (Aeson.eitherDecode bytes :: Either String (ListOf Aeson.Value))
      decodeStatus (listWith ",\"request_status\":{\"type\":\"incomplete\",\"incomplete_reason\":\"query_result_limit_reached\"}")
        @?= Right (Just (RequestStatus RequestIncomplete (Just QueryResultLimitReached)))
      decodeStatus (listWith "") @?= Right Nothing
      decodeStatus (listWith ",\"request_status\":{\"type\":\"partial\"}")
        @?= Right (Just (RequestStatus (UnknownRequestStatusType "partial") Nothing))
  ]
  where
    isUnknown = \case
      UnknownErrorCode _ -> True
      _ -> False
    fromExceptionOf :: SomeException -> Maybe RequestTimeoutError
    fromExceptionOf = fromException

------------------------------------------------------------------------------
-- Retries

retryPolicyTests :: [TestTree]
retryPolicyTests =
  [ testCase "canRetry follows the JS SDK rules" $ do
      canRetry HTTP.methodPost RateLimited @?= True
      canRetry HTTP.methodPost ServiceOverload @?= True
      canRetry HTTP.methodPost InternalServerError @?= False
      canRetry HTTP.methodGet InternalServerError @?= True
      canRetry HTTP.methodDelete ServiceUnavailable @?= True
      canRetry HTTP.methodPatch ServiceUnavailable @?= False
      canRetry HTTP.methodGet GatewayTimeout @?= False
      canRetry HTTP.methodGet (UnknownErrorCode "x") @?= False
      canRetry HTTP.methodGet ObjectNotFound @?= False,
    testCase "parseRetryAfter reads seconds and HTTP dates" $ do
      let now = UTCTime (fromGregorian 2015 10 21) (secondsToDiffTime (7 * 3600 + 27 * 60 + 30))
      parseRetryAfter now "120" @?= Just 120
      parseRetryAfter now "0" @?= Just 0
      parseRetryAfter now " 7" @?= Just 7
      parseRetryAfter now "1.5" @?= Just 1
      parseRetryAfter now "Wed, 21 Oct 2015 07:28:00 GMT" @?= Just 30
      parseRetryAfter now "Wed, 21 Oct 2015 07:00:00 GMT" @?= Just 0
      parseRetryAfter now "soon" @?= Nothing
      parseRetryAfter now "" @?= Nothing,
    testCase "retryDelay uses back-off with jitter and caps retry-after" $ do
      retryDelay defaultRetryOptions 0 0 Nothing @?= 0.5
      retryDelay defaultRetryOptions 1 0.5 Nothing @?= 2
      retryDelay defaultRetryOptions 10 0.9 Nothing @?= 60
      retryDelay defaultRetryOptions 0 0 (Just 120) @?= 60
      retryDelay defaultRetryOptions 0 0 (Just 5) @?= 5,
    testCase "validateRequestPath rejects path traversal" $ do
      validateRequestPath "/pages/5c6a28216bb14a7eb6e1c50111515c3d" @?= Right ()
      validateRequestPath "/pages/.." @?= Left (InvalidPathParameterError "/pages/..")
      validateRequestPath "/pages/%2E%2E" @?= Left (InvalidPathParameterError "/pages/%2E%2E")
      validateRequestPath "/pages/%252e%252e" @?= Right ()
  ]

-- | Retries with millisecond delays so the tests run quickly.
fastRetryConfig :: ClientConfig
fastRetryConfig =
  defaultClientConfig {retryOptions = defaultRetryOptions {initialRetryDelay = 0.001, maxRetryDelay = 0.01}}

errorReply :: Int -> Text -> FakeReply
errorReply status code =
  jsonReply status $
    "{\"object\":\"error\",\"status\":"
      <> L8.pack (show status)
      <> ",\"code\":\""
      <> L8.pack (Text.unpack code)
      <> "\",\"message\":\"scripted failure\"}"

rateLimitedReply :: FakeReply
rateLimitedReply =
  FakeReply
    429
    [("Content-Type", "application/json"), ("Retry-After", "0")]
    "{\"object\":\"error\",\"status\":429,\"code\":\"rate_limited\",\"message\":\"You have been rate limited. Please try again in a few minutes.\"}"

emptyListJson :: L8.ByteString
emptyListJson = "{\"object\":\"list\",\"results\":[],\"next_cursor\":null,\"has_more\":false}"

emptySearch :: SearchRequest
emptySearch = SearchRequest {query = Nothing, sort = Nothing, filter = Nothing, startCursor = Nothing, pageSize = Nothing}

-- | Run a call against a scripted fake and return the result and request count.
runScripted :: ClientConfig -> [FakeReply] -> (Methods -> IO a) -> IO (Either SomeException a, Int)
runScripted config script call = do
  (env, recorded) <- fakeClientEnv script
  result <- try (call (makeMethodsWithEnv config env "secret_tanaka"))
  n <- length <$> readIORef recorded
  pure (result, n)

expectCode :: APIErrorCode -> Either SomeException a -> Assertion
expectCode expected = \case
  Left ex | Just NotionError {code} <- fromException ex -> code @?= expected
  Left ex -> assertFailure ("expected NotionError, got " <> show ex)
  Right _ -> assertFailure "expected a failure"

retryLoopTests :: [TestTree]
retryLoopTests =
  [ testCase "GET retried after 429 then succeeds" $ do
      (result, n) <- runScripted fastRetryConfig [rateLimitedReply, jsonReply 200 userJson] retrieveMyUser
      either (assertFailure . show) (const (pure ())) result
      n @?= 2,
    testCase "POST retried after 529" $ do
      (result, n) <- runScripted fastRetryConfig [errorReply 529 "service_overload", jsonReply 200 emptyListJson] (`search` emptySearch)
      either (assertFailure . show) (const (pure ())) result
      n @?= 2,
    testCase "POST not retried on 500" $ do
      (result, n) <- runScripted fastRetryConfig [errorReply 500 "internal_server_error", jsonReply 200 emptyListJson] (`search` emptySearch)
      expectCode InternalServerError result
      n @?= 1,
    testCase "GET retried on 503 until maxRetries then throws" $ do
      (result, n) <- runScripted fastRetryConfig (replicate 3 (errorReply 503 "service_unavailable")) retrieveMyUser
      expectCode ServiceUnavailable result
      n @?= 3,
    testCase "noRetries disables retries" $ do
      (result, n) <- runScripted defaultClientConfig {retryOptions = noRetries} [rateLimitedReply, jsonReply 200 userJson] retrieveMyUser
      expectCode RateLimited result
      n @?= 1,
    testCase "withRetries wraps a plain IO action" $ do
      calls <- newIORef (0 :: Int)
      let failWith status = do
            modifyIORef' calls (+ 1)
            count <- readIORef calls
            if count == 1
              then throwIO (notionErrorFromResponse status [("Retry-After", "0")] (rateLimitedBodyFor status))
              else pure ("ok" :: Text)
      r <- withRetries fastRetryConfig HTTP.methodPost "/sessions" (failWith HTTP.status429)
      r @?= "ok"
      readIORef calls >>= (@?= 2)
      calls2 <- newIORef (0 :: Int)
      let alwaysFail = modifyIORef' calls2 (+ 1) >> throwIO (notionErrorFromResponse HTTP.status500 [] (rateLimitedBodyFor HTTP.status500))
      r2 <- try @NotionError (withRetries fastRetryConfig HTTP.methodPost "/sessions" (alwaysFail :: IO Text))
      either (\NotionError {code} -> code @?= InternalServerError) (const (assertFailure "expected failure")) r2
      readIORef calls2 >>= (@?= 1),
    testCase "HTML 429 is not retried" $ do
      (result, n) <- runScripted fastRetryConfig [FakeReply 429 [("Content-Type", "text/html")] "<html/>", jsonReply 200 userJson] retrieveMyUser
      case result of
        Left ex | Just (_ :: UnknownHTTPResponseError) <- fromException ex -> pure ()
        other -> assertFailure ("expected UnknownHTTPResponseError, got " <> either show (const "success") other)
      n @?= 1,
    testCase "logger sees retry lines" $ do
      logged <- newIORef []
      let config = fastRetryConfig {logger = Just (\_ msg _ -> modifyIORef' logged (<> [msg])), logLevel = LogDebug}
      (result, _) <- runScripted config [rateLimitedReply, jsonReply 200 userJson] retrieveMyUser
      either (assertFailure . show) (const (pure ())) result
      messages <- readIORef logged
      Prelude.filter (`elem` ["request start", "request fail", "retrying request", "request success"]) messages
        @?= ["request start", "request fail", "retrying request", "request success"],
    testCase "path traversal rejected before sending" $ do
      (result, n) <- runScripted fastRetryConfig [jsonReply 200 userJson] (`retrievePage` UUID "..")
      case result of
        Left ex | Just (_ :: InvalidPathParameterError) <- fromException ex -> pure ()
        other -> assertFailure ("expected InvalidPathParameterError, got " <> either show (const "success") other)
      n @?= 0
  ]
  where
    rateLimitedBodyFor status =
      let HTTP.Status {HTTP.statusCode = c} = status
          codeText :: L8.ByteString
          codeText = if c == 429 then "rate_limited" else "internal_server_error"
       in "{\"object\":\"error\",\"status\":" <> L8.pack (show c) <> ",\"code\":\"" <> codeText <> "\",\"message\":\"scripted failure\"}"