packages feed

notion-client-0.8.0.0: tasty/AsyncTaskTests.hs

-- | Async task decoding, @allow_async@ requests and polling (EP-3).
module AsyncTaskTests (tests) where

import Control.Exception (Exception, throwIO, try)
import Data.Aeson (Value)
import Data.Aeson qualified as Aeson
import Data.Aeson.KeyMap qualified as KeyMap
import Data.ByteString.Lazy qualified as LBS
import Data.IORef (atomicModifyIORef', newIORef, readIORef, writeIORef)
import Data.Map qualified as Map
import FakeNotion
import Network.HTTP.Client qualified as HTTP
import Notion.V1 (Methods (..), makeMethods)
import Notion.V1.AsyncTasks
import Notion.V1.Common (Parent (..), UUID (..))
import Notion.V1.Error (APIErrorCode (..))
import Notion.V1.Pages (CreatePage (..), ReplaceContentRequest (..), UpdatePageMarkdown (..), mkCreatePage)
import Servant.Client qualified as Client
import Test.Tasty
import Test.Tasty.HUnit

tests :: TestTree
tests =
  testGroup
    "Async tasks (EP-3)"
    [ testCase "Decode running task" testRunning,
      testCase "Decode succeeded task" testSucceeded,
      testCase "Decode failed task" testFailed,
      testCase "Unknown status and surface are tolerated" testUnknown,
      testCase "AsyncTask round-trips" testRoundTrip,
      testCase "AsyncOr picks async_task" testAsyncOr,
      testCase "AllowAsync adds the flag" testAllowAsync,
      testCase "waitForAsyncTask polls until terminal" testWaitUntilTerminal,
      testCase "waitForAsyncTask stops at maxAttempts" testWaitMaxAttempts,
      testCase "createPageAsync sends allow_async to POST /pages" testCreatePageAsyncRequest,
      testCase "retrieveAsyncTask and updatePageMarkdownAsync (202) routes" testRoutes
    ]

runningFixture :: LBS.ByteString
runningFixture =
  "{\"object\":\"async_task\",\"id\":\"task_01\",\
  \\"status_url\":\"https://api.notion.com/v1/async_tasks/task_01\",\
  \\"created_time\":\"2026-09-14T10:00:00.000Z\",\
  \\"operation\":{\"surface\":\"rest\",\"name\":\"pages.create\"},\
  \\"status\":\"running\",\"poll_after_seconds\":2}"

succeededFixture :: LBS.ByteString
succeededFixture =
  "{\"object\":\"async_task\",\"id\":\"task_01\",\
  \\"status_url\":\"https://api.notion.com/v1/async_tasks/task_01\",\
  \\"created_time\":\"2026-09-14T10:00:00.000Z\",\
  \\"operation\":{\"surface\":\"rest\",\"name\":\"pages.create\"},\
  \\"status\":\"succeeded\",\"result\":{\"page_id\":\"5c6a2821-0000-4000-8000-00000000000a\"}}"

failedFixture :: LBS.ByteString
failedFixture =
  "{\"object\":\"async_task\",\"id\":\"task_02\",\
  \\"status_url\":\"https://api.notion.com/v1/async_tasks/task_02\",\
  \\"created_time\":\"2026-09-14T10:00:00.000Z\",\
  \\"operation\":{\"surface\":\"mcp\",\"name\":\"pages.update_markdown\"},\
  \\"status\":\"failed\",\"error\":{\"object\":\"error\",\"status\":400,\
  \\"code\":\"validation_error\",\"message\":\"markdown is required\",\
  \\"additional_data\":{\"field\":[\"markdown\"]}}}"

unknownFixture :: LBS.ByteString
unknownFixture =
  "{\"object\":\"async_task\",\"id\":\"task_03\",\
  \\"status_url\":\"https://api.notion.com/v1/async_tasks/task_03\",\
  \\"created_time\":\"2026-09-14T10:00:00.000Z\",\
  \\"operation\":{\"surface\":\"cli\",\"name\":\"pages.create\"},\
  \\"status\":\"cancelled\"}"

decodeTask :: LBS.ByteString -> IO AsyncTask
decodeTask bs = either (assertFailure . ("decode: " <>)) pure (Aeson.eitherDecode bs)

testRunning :: Assertion
testRunning = do
  AsyncTask {status, operation = AsyncTaskOperation {surface, name}} <- decodeTask runningFixture
  status @?= AsyncTaskRunning 2.0
  surface @?= SurfaceRest
  name @?= "pages.create"

testSucceeded :: Assertion
testSucceeded = do
  AsyncTask {status} <- decodeTask succeededFixture
  case status of
    AsyncTaskSucceeded result -> assertBool "page_id in result" (KeyMap.member "page_id" result)
    other -> assertFailure ("expected AsyncTaskSucceeded, got " <> show other)

testFailed :: Assertion
testFailed = do
  AsyncTask {status, operation = AsyncTaskOperation {surface}} <- decodeTask failedFixture
  surface @?= SurfaceMcp
  case status of
    AsyncTaskFailed AsyncTaskError {status = errStatus, code, additionalData} -> do
      errStatus @?= 400
      code @?= ValidationError
      assertBool "additional_data.field" (maybe False (KeyMap.member "field") additionalData)
    other -> assertFailure ("expected AsyncTaskFailed, got " <> show other)

testUnknown :: Assertion
testUnknown = do
  task@AsyncTask {status, operation = AsyncTaskOperation {surface}} <- decodeTask unknownFixture
  status @?= UnknownAsyncTaskStatus "cancelled"
  surface @?= UnknownSurface "cli"
  assertBool "unknown status is terminal" (isTerminal task)

testRoundTrip :: Assertion
testRoundTrip =
  mapM_
    ( \fixture -> do
        task <- decodeTask fixture
        Aeson.eitherDecode (Aeson.encode task) @?= Right task
    )
    [runningFixture, succeededFixture, failedFixture, unknownFixture]

testAsyncOr :: Assertion
testAsyncOr = do
  case Aeson.eitherDecode runningFixture :: Either String (AsyncOr Value) of
    Right (AcceptedAsync _) -> pure ()
    other -> assertFailure ("expected AcceptedAsync, got " <> show other)
  case Aeson.eitherDecode "{\"object\":\"page\",\"id\":\"p\"}" :: Either String (AsyncOr Value) of
    Right (CompletedSync _) -> pure ()
    other -> assertFailure ("expected CompletedSync, got " <> show other)

testAllowAsync :: Assertion
testAllowAsync = do
  expected <-
    either assertFailure pure $
      Aeson.eitherDecode
        "{\"type\":\"replace_content\",\"replace_content\":{\"new_str\":\"new\"},\"allow_async\":true}"
  Aeson.toJSON (AllowAsync (ReplaceContent (ReplaceContentRequest "new" Nothing))) @?= (expected :: Value)

-- | A pending task that asks to be polled again immediately.
pendingTask :: IO AsyncTask
pendingTask = do
  AsyncTask {id = taskId, statusUrl, createdTime, operation, object} <- decodeTask runningFixture
  pure AsyncTask {id = taskId, statusUrl, createdTime, operation, object, status = AsyncTaskRunning 0}

testWaitUntilTerminal :: Assertion
testWaitUntilTerminal = do
  start <- pendingTask
  done <- decodeTask succeededFixture
  script <- newIORef [start, done]
  calls <- newIORef (0 :: Int)
  let retrieve _ = do
        atomicModifyIORef' calls (\n -> (n + 1, ()))
        atomicModifyIORef' script (\case t : ts -> (ts, t); [] -> ([], done))
  AsyncTask {status} <- waitForAsyncTask defaultWaitOptions retrieve start
  case status of
    AsyncTaskSucceeded _ -> pure ()
    other -> assertFailure ("expected AsyncTaskSucceeded, got " <> show other)
  readIORef calls >>= (@?= 2)

testWaitMaxAttempts :: Assertion
testWaitMaxAttempts = do
  start <- pendingTask
  calls <- newIORef (0 :: Int)
  let retrieve _ = atomicModifyIORef' calls (\n -> (n + 1, start))
  result <- waitForAsyncTask WaitOptions {maxAttempts = 3, maxPollSeconds = 1} retrieve start
  assertBool "result is still pending" (not (isTerminal result))
  readIORef calls >>= (@?= 3)

data RequestCaptured = RequestCaptured deriving stock (Show)

instance Exception RequestCaptured

-- | Build a request through 'Methods' without sending it.
captureRequest :: (Methods -> IO a) -> IO HTTP.Request
captureRequest call = do
  ref <- newIORef Nothing
  manager <- HTTP.newManager HTTP.defaultManagerSettings
  let env0 = Client.mkClientEnv manager fakeBaseUrl
      env =
        env0
          { Client.makeClientRequest = \burl req -> do
              built <- Client.defaultMakeClientRequest burl req
              writeIORef ref (Just built)
              throwIO RequestCaptured
          }
  _ <- try @RequestCaptured (call (makeMethods env "secret_test_token"))
  readIORef ref >>= maybe (assertFailure "no request was built") pure

testCreatePageAsyncRequest :: Assertion
testCreatePageAsyncRequest = do
  let CreatePage {..} = mkCreatePage (PageParent (UUID "p-1")) Map.empty
      page = CreatePage {markdown = Just "# Hello", ..}
  req <- captureRequest (\m -> createPageAsync m page)
  HTTP.method req @?= "POST"
  HTTP.path req @?= "/v1/pages"
  case HTTP.requestBody req of
    HTTP.RequestBodyLBS lbs -> case Aeson.decode lbs of
      Just (Aeson.Object o) -> do
        KeyMap.lookup "allow_async" o @?= Just (Aeson.Bool True)
        KeyMap.lookup "markdown" o @?= Just (Aeson.String "# Hello")
      other -> assertFailure ("expected a JSON object body, got " <> show other)
    _ -> assertFailure "expected a lazy ByteString body"

testRoutes :: Assertion
testRoutes = do
  (env, recorded) <- fakeClientEnv [jsonReply 200 succeededFixture, jsonReply 202 runningFixture]
  let Methods {retrieveAsyncTask, updatePageMarkdownAsync} = makeMethods env "secret_test"
  AsyncTask {id = taskId} <- retrieveAsyncTask "task_01"
  taskId @?= "task_01"
  updated <- updatePageMarkdownAsync (UUID "p-1") (ReplaceContent (ReplaceContentRequest "new" Nothing))
  case updated of
    AcceptedAsync _ -> pure ()
    CompletedSync _ -> assertFailure "expected AcceptedAsync"
  requests <- readIORef recorded
  map (\Recorded {method, path} -> (method, path)) requests
    @?= [("GET", "/async_tasks/task_01"), ("PATCH", "/pages/p-1/markdown")]