packages feed

notion-client-0.3.0.0: tasty/Main.hs

module Main where

import Data.Aeson qualified as Aeson
import Data.Aeson.KeyMap qualified as KeyMap
import Data.ByteString.Lazy qualified as LBS
import Data.Text qualified as Text
import Data.Vector qualified as Vector
import Notion.V1
import Notion.V1.Blocks (AppendBlockChildren (..), BlockObject (..), Position (..))
import Notion.V1.Common (UUID (..))
import Notion.V1.DataSources (DataSourceObject (..))
import Notion.V1.Databases (DatabaseObject (..))
import Notion.V1.ListOf (ListOf (..))
import Notion.V1.Pages (PageMarkdown (..), PageObject (..))
import Notion.V1.Search (SearchRequest (..))
import Notion.V1.Users (UserObject (..))
import System.Environment qualified as Environment
import Test.Tasty
import Test.Tasty.HUnit

main :: IO ()
main = do
  defaultMain =<< tests

tests :: IO TestTree
tests = do
  mToken <- lookupEnv "NOTION_TOKEN"

  let integrationTests = case mToken of
        Nothing ->
          testGroup
            "Integration Tests (skipped — no NOTION_TOKEN)"
            [ testCase "No API token found" $
                assertBool "Set NOTION_TOKEN environment variable to run integration tests" True
            ]
        Just token -> unsafePerformIOTestTree token

  mPageId <- lookupEnv "NOTION_TEST_PAGE_ID"

  let markdownTests = case (mToken, mPageId) of
        (Just token, Just pageId) ->
          testGroup
            "Markdown Tests"
            [ testCase "Retrieve page as markdown" $ do
                clientEnv <- getClientEnv "https://api.notion.com/v1"
                let methods = makeMethods clientEnv (Text.pack token)
                testRetrievePageMarkdown methods (Text.pack pageId)
            ]
        _ ->
          testGroup "Markdown Tests (skipped)" []

  pure $
    testGroup
      "Notion Client Tests"
      [ jsonParsingTests,
        jsonSerializationTests,
        integrationTests,
        markdownTests
      ]

unsafePerformIOTestTree :: String -> TestTree
unsafePerformIOTestTree token =
  testGroup
    "Integration Tests"
    [ testCase "Retrieve current user" $ do
        clientEnv <- getClientEnv "https://api.notion.com/v1"
        let methods = makeMethods clientEnv (Text.pack token)
        testRetrieveCurrentUser methods,
      testCase "List users" $ do
        clientEnv <- getClientEnv "https://api.notion.com/v1"
        let methods = makeMethods clientEnv (Text.pack token)
        testListUsers methods,
      testCase "Timestamp parsing works" $ do
        clientEnv <- getClientEnv "https://api.notion.com/v1"
        let methods = makeMethods clientEnv (Text.pack token)
        testSearchAPI methods
    ]

-- ---------------------------------------------------------------------
-- JSON Parsing Tests (unit tests, no API token needed)
-- ---------------------------------------------------------------------

jsonParsingTests :: TestTree
jsonParsingTests =
  testGroup
    "JSON Parsing"
    [ testCase "Parse BlockObject with in_trash" testParseBlockObject,
      testCase "Parse BlockObject with legacy archived field" testParseBlockObjectLegacy,
      testCase "Parse PageObject with in_trash" testParsePageObject,
      testCase "Parse DatabaseObject with in_trash" testParseDatabaseObject,
      testCase "Parse DataSourceObject with in_trash" testParseDataSourceObject
    ]

testParseBlockObject :: Assertion
testParseBlockObject = do
  let json =
        "{\"object\":\"block\",\"id\":\"abc-123\",\"parent\":{\"type\":\"page_id\",\"page_id\":\"parent-1\"}"
          <> ",\"created_time\":\"2025-10-01T12:00:00.000+00:00\""
          <> ",\"last_edited_time\":\"2025-10-01T12:30:00.000+00:00\""
          <> ",\"created_by\":{\"object\":\"user\",\"id\":\"user-1\"}"
          <> ",\"last_edited_by\":{\"object\":\"user\",\"id\":\"user-2\"}"
          <> ",\"has_children\":false"
          <> ",\"in_trash\":false"
          <> ",\"type\":\"paragraph\""
          <> ",\"paragraph\":{\"rich_text\":[]}}"
  case Aeson.eitherDecode json of
    Left err -> assertFailure $ "Failed to parse BlockObject: " <> err
    Right block -> do
      assertEqual "inTrash should be False" False (Notion.V1.Blocks.inTrash block)
      assertEqual "type should be paragraph" "paragraph" (Notion.V1.Blocks.type_ block)

testParseBlockObjectLegacy :: Assertion
testParseBlockObjectLegacy = do
  -- Test backward compatibility: old "archived" field still parses
  let json =
        "{\"object\":\"block\",\"id\":\"abc-123\",\"parent\":{\"type\":\"page_id\",\"page_id\":\"parent-1\"}"
          <> ",\"created_time\":\"2025-10-01T12:00:00.000+00:00\""
          <> ",\"last_edited_time\":\"2025-10-01T12:30:00.000+00:00\""
          <> ",\"created_by\":{\"object\":\"user\",\"id\":\"user-1\"}"
          <> ",\"last_edited_by\":{\"object\":\"user\",\"id\":\"user-2\"}"
          <> ",\"has_children\":false"
          <> ",\"archived\":true"
          <> ",\"type\":\"paragraph\""
          <> ",\"paragraph\":{\"rich_text\":[]}}"
  case Aeson.eitherDecode json of
    Left err -> assertFailure $ "Failed to parse BlockObject with legacy archived: " <> err
    Right block -> do
      assertEqual "inTrash should be True (from legacy archived)" True (Notion.V1.Blocks.inTrash block)

testParsePageObject :: Assertion
testParsePageObject = do
  let json =
        "{\"object\":\"page\",\"id\":\"page-123\""
          <> ",\"created_time\":\"2025-08-07T10:11:07.504+00:00\""
          <> ",\"last_edited_time\":\"2025-08-10T15:53:11.386+00:00\""
          <> ",\"created_by\":{\"object\":\"user\",\"id\":\"user-1\"}"
          <> ",\"last_edited_by\":{\"object\":\"user\",\"id\":\"user-2\"}"
          <> ",\"cover\":null,\"icon\":null"
          <> ",\"parent\":{\"type\":\"page_id\",\"page_id\":\"parent-1\"}"
          <> ",\"in_trash\":false"
          <> ",\"properties\":{}"
          <> ",\"url\":\"https://www.notion.so/page-123\"}"
  case Aeson.eitherDecode json of
    Left err -> assertFailure $ "Failed to parse PageObject: " <> err
    Right page -> do
      assertEqual "inTrash should be False" False (Notion.V1.Pages.inTrash page)
      assertEqual "url should match" "https://www.notion.so/page-123" (Notion.V1.Pages.url page)

testParseDatabaseObject :: Assertion
testParseDatabaseObject = do
  let json =
        "{\"object\":\"database\",\"id\":\"db-123\""
          <> ",\"created_time\":\"2025-08-07T10:11:07.504+00:00\""
          <> ",\"last_edited_time\":\"2025-08-10T15:53:11.386+00:00\""
          <> ",\"title\":[],\"url\":\"https://www.notion.so/db-123\""
          <> ",\"parent\":{\"type\":\"page_id\",\"page_id\":\"parent-1\"}"
          <> ",\"in_trash\":false"
          <> ",\"data_sources\":[]}"
  case Aeson.eitherDecode json of
    Left err -> assertFailure $ "Failed to parse DatabaseObject: " <> err
    Right db -> do
      assertEqual "inTrash should be Just False" (Just False) (Notion.V1.Databases.inTrash db)

testParseDataSourceObject :: Assertion
testParseDataSourceObject = do
  let json =
        "{\"object\":\"data_source\",\"id\":\"ds-123\""
          <> ",\"created_time\":\"2025-08-07T10:11:07.504+00:00\""
          <> ",\"last_edited_time\":\"2025-08-10T15:53:11.386+00:00\""
          <> ",\"created_by\":{\"object\":\"user\",\"id\":\"user-1\"}"
          <> ",\"last_edited_by\":{\"object\":\"user\",\"id\":\"user-2\"}"
          <> ",\"title\":[],\"description\":[],\"properties\":{}"
          <> ",\"url\":\"https://www.notion.so/ds-123\""
          <> ",\"parent\":{\"type\":\"database_id\",\"database_id\":\"db-1\"}"
          <> ",\"in_trash\":false}"
  case Aeson.eitherDecode json of
    Left err -> assertFailure $ "Failed to parse DataSourceObject: " <> err
    Right ds -> do
      assertEqual "inTrash should be Just False" (Just False) (Notion.V1.DataSources.inTrash ds)

-- ---------------------------------------------------------------------
-- JSON Serialization Tests
-- ---------------------------------------------------------------------

jsonSerializationTests :: TestTree
jsonSerializationTests =
  testGroup
    "JSON Serialization"
    [ testCase "AppendBlockChildren without position" testSerializeAppendNoPosition,
      testCase "AppendBlockChildren with AfterBlock position" testSerializeAppendAfterBlock,
      testCase "AppendBlockChildren with Start position" testSerializeAppendStart,
      testCase "AppendBlockChildren with End position" testSerializeAppendEnd
    ]

testSerializeAppendNoPosition :: Assertion
testSerializeAppendNoPosition = do
  let req = AppendBlockChildren {children = Vector.empty, position = Nothing}
      json = Aeson.toJSON req
  case json of
    Aeson.Object o -> do
      assertBool "should have children key" (KeyMap.member "children" o)
      assertBool "should not have position key" (not $ KeyMap.member "position" o)
    _ -> assertFailure "Expected JSON object"

testSerializeAppendAfterBlock :: Assertion
testSerializeAppendAfterBlock = do
  let req = AppendBlockChildren {children = Vector.empty, position = Just (AfterBlock (UUID "block-42"))}
      json = Aeson.toJSON req
  case json of
    Aeson.Object o -> do
      assertBool "should have position key" (KeyMap.member "position" o)
      case KeyMap.lookup "position" o of
        Just (Aeson.Object pos) -> do
          assertEqual "position type" (Just (Aeson.String "after_block")) (KeyMap.lookup "type" pos)
          case KeyMap.lookup "after_block" pos of
            Just (Aeson.Object ab) ->
              assertEqual "after_block id" (Just (Aeson.String "block-42")) (KeyMap.lookup "id" ab)
            _ -> assertFailure "Expected after_block object"
        _ -> assertFailure "Expected position object"
    _ -> assertFailure "Expected JSON object"

testSerializeAppendStart :: Assertion
testSerializeAppendStart = do
  let req = AppendBlockChildren {children = Vector.empty, position = Just Start}
      json = Aeson.toJSON req
  case json of
    Aeson.Object o ->
      case KeyMap.lookup "position" o of
        Just (Aeson.Object pos) ->
          assertEqual "position type" (Just (Aeson.String "start")) (KeyMap.lookup "type" pos)
        _ -> assertFailure "Expected position object"
    _ -> assertFailure "Expected JSON object"

testSerializeAppendEnd :: Assertion
testSerializeAppendEnd = do
  let req = AppendBlockChildren {children = Vector.empty, position = Just End}
      json = Aeson.toJSON req
  case json of
    Aeson.Object o ->
      case KeyMap.lookup "position" o of
        Just (Aeson.Object pos) ->
          assertEqual "position type" (Just (Aeson.String "end")) (KeyMap.lookup "type" pos)
        _ -> assertFailure "Expected position object"
    _ -> assertFailure "Expected JSON object"

-- ---------------------------------------------------------------------
-- Integration test helpers
-- ---------------------------------------------------------------------

testRetrieveCurrentUser :: Methods -> Assertion
testRetrieveCurrentUser Methods {retrieveMyUser} = do
  user <- retrieveMyUser
  let UserObject {id = userId} = user
  assertBool "User object should have an ID" (show userId /= "")

testListUsers :: Methods -> Assertion
testListUsers Methods {listUsers} = do
  users <- listUsers Nothing Nothing
  let userCount = length $ results users
  assertBool "Should have at least one user" (userCount > 0)

testSearchAPI :: Methods -> Assertion
testSearchAPI Methods {search} = do
  let searchParams =
        Notion.V1.Search.SearchRequest
          { query = Just "test",
            sort = Nothing,
            filter = Nothing,
            startCursor = Nothing,
            pageSize = Nothing
          }
  _ <- search searchParams
  assertBool "Search should run without timestamp parsing errors" True

testRetrievePageMarkdown :: Methods -> Text.Text -> Assertion
testRetrievePageMarkdown Methods {retrievePageMarkdown} pageId = do
  result <- retrievePageMarkdown (UUID pageId) Nothing
  let PageMarkdown {markdown = md} = result
  assertBool "Markdown content should not be empty" (not $ Text.null md)

lookupEnv :: String -> IO (Maybe String)
lookupEnv var = Environment.lookupEnv var