packages feed

notion-client-0.3.1.0: tasty/Main.hs

module Main where

import Data.Aeson qualified as Aeson
import Data.Aeson.Key qualified as Key
import Data.Aeson.KeyMap qualified as KeyMap
import Data.Map qualified as Map
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.Blocks qualified as Blocks
import Notion.V1.Comments (CommentObject (..), CreateComment (..))
import Notion.V1.Comments qualified as Comments
import Notion.V1.Common (Icon (..), Parent (..), UUID (..))
import Notion.V1.CustomEmojis (CustomEmoji (..))
import Notion.V1.DataSources (DataSourceObject (..))
import Notion.V1.DataSources qualified as DataSources
import Notion.V1.Databases (DataSource (..), DatabaseObject (..))
import Notion.V1.Databases qualified as Databases
import Notion.V1.ListOf (ListOf (..))
import Notion.V1.Pages
  ( ContentUpdate (..),
    CreatePage (..),
    MovePage (..),
    PageMarkdown (..),
    PageObject (..),
    PropertyValue (..),
    PropertyValueType (..),
    ReplaceContentRequest (..),
    Template (..),
    UpdateContentRequest (..),
    UpdatePage (..),
    UpdatePageMarkdown (..),
    mkCreatePage,
    mkUpdatePage,
  )
import Notion.V1.RichText (Annotations (..), RichTextContent (..), TextContent (..), defaultAnnotations)
import Notion.V1.RichText qualified as RT
import Notion.V1.Search (SearchRequest (..), SearchResult (..), dataSourceFilter, pageFilter, parseSearchResults)
import Notion.V1.Users (UserObject (..))
import Notion.V1.Views (CreateView (..), QueryView (..), UpdateView (..), ViewObject (..), ViewType (..))
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"
  mPageId <- lookupEnv "NOTION_TEST_PAGE_ID"
  mDatabaseId <- lookupEnv "NOTION_TEST_DATABASE_ID"

  let basicIntegration = 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 ->
          testGroup
            "Integration Tests"
            [ testCase "Retrieve current user" $ do
                methods <- mkMethods token
                testRetrieveCurrentUser methods,
              testCase "List users" $ do
                methods <- mkMethods token
                testListUsers methods,
              testCase "Search all objects" $ do
                methods <- mkMethods token
                testSearchAll methods,
              testCase "Search filtered by page" $ do
                methods <- mkMethods token
                testSearchPages methods,
              testCase "Search filtered by data source" $ do
                methods <- mkMethods token
                testSearchDataSources methods,
              testCase "List custom emojis" $ do
                methods <- mkMethods token
                testListCustomEmojis methods
            ]

  let markdownE2E = case (mToken, mPageId) of
        (Just token, Just pageId) ->
          testGroup
            "Markdown E2E"
            [ testCase "Retrieve page as markdown" $ do
                methods <- mkMethods token
                testRetrievePageMarkdown methods (Text.pack pageId),
              testCase "Create page with markdown, edit, and clean up" $ do
                methods <- mkMethods token
                testMarkdownLifecycle methods (Text.pack pageId)
            ]
        _ ->
          testGroup "Markdown E2E (skipped — need NOTION_TOKEN + NOTION_TEST_PAGE_ID)" []

  let pageE2E = case (mToken, mPageId) of
        (Just token, Just pageId) ->
          testGroup
            "Page E2E"
            [ testCase "Create page with blocks, append children, list, and clean up" $ do
                methods <- mkMethods token
                testPageBlockLifecycle methods (Text.pack pageId),
              testCase "Create page, add comments, list comments, and clean up" $ do
                methods <- mkMethods token
                testCommentLifecycle methods (Text.pack pageId),
              testCase "Create page, move to different parent, and clean up" $ do
                methods <- mkMethods token
                testMovePageLifecycle methods (Text.pack pageId)
            ]
        _ ->
          testGroup "Page E2E (skipped — need NOTION_TOKEN + NOTION_TEST_PAGE_ID)" []

  let databaseE2E = case (mToken, mDatabaseId) of
        (Just token, Just dbId) ->
          testGroup
            "Database E2E"
            [ testCase "Retrieve database and data source" $ do
                methods <- mkMethods token
                testRetrieveDatabaseAndDataSource methods (Text.pack dbId),
              testCase "List data source templates" $ do
                methods <- mkMethods token
                testListTemplates methods (Text.pack dbId),
              testCase "Query data source" $ do
                methods <- mkMethods token
                testQueryDataSource methods (Text.pack dbId),
              testCase "Create page with markdown in database and clean up" $ do
                methods <- mkMethods token
                testCreateMarkdownPageInDatabase methods (Text.pack dbId)
            ]
        _ ->
          testGroup "Database E2E (skipped — need NOTION_TOKEN + NOTION_TEST_DATABASE_ID)" []

  let viewE2E = case (mToken, mDatabaseId) of
        (Just token, Just dbId) ->
          testGroup
            "View E2E"
            [ testCase "Create, retrieve, update, list, query, and delete view" $ do
                methods <- mkMethods token
                testViewLifecycle methods (Text.pack dbId)
            ]
        _ ->
          testGroup "View E2E (skipped — need NOTION_TOKEN + NOTION_TEST_DATABASE_ID)" []

  pure $
    testGroup
      "Notion Client Tests"
      [ jsonParsingTests,
        jsonSerializationTests,
        basicIntegration,
        markdownE2E,
        pageE2E,
        databaseE2E,
        viewE2E
      ]

-- | Create Methods from a token string
mkMethods :: String -> IO Methods
mkMethods token = do
  clientEnv <- getClientEnv "https://api.notion.com/v1"
  pure $ makeMethods clientEnv (Text.pack token)

-- | Helper to make a rich text array from a plain string
mkRichTextValue :: Text.Text -> Aeson.Value
mkRichTextValue t =
  Aeson.Array . Vector.singleton $
    Aeson.object [("text", Aeson.object [("content", Aeson.String t)])]

-- | Helper to make a paragraph block JSON value
mkParagraphBlock :: Text.Text -> Aeson.Value
mkParagraphBlock t =
  Aeson.object
    [ ("type", Aeson.String "paragraph"),
      ("paragraph", Aeson.object [("rich_text", mkRichTextValue t)])
    ]

-- | Helper to make a heading block JSON value
mkHeadingBlock :: Text.Text -> Int -> Aeson.Value
mkHeadingBlock t level =
  let headingType = "heading_" <> Text.pack (show level)
   in Aeson.object
        [ ("type", Aeson.String headingType),
          (Key.fromText headingType, Aeson.object [("rich_text", mkRichTextValue t)])
        ]

-- | Helper to create a simple test page under a parent page
createTestPage :: Methods -> Text.Text -> Text.Text -> IO PageObject
createTestPage Methods {createPage} parentPageId title = do
  let titleProp = Aeson.object [("title", mkRichTextValue title)]
      props = Map.fromList [("title", PropertyValue {type_ = Title, value = Just titleProp})]
      req = mkCreatePage (PageParent {pageId = UUID parentPageId}) props
  createPage req

-- | Helper to trash a page (clean up after tests)
trashPage :: Methods -> UUID -> IO ()
trashPage Methods {updatePage} pageId = do
  let req =
        UpdatePage
          { properties = Map.empty,
            inTrash = Just True,
            icon = Nothing,
            cover = Nothing,
            template = Nothing,
            eraseContent = Nothing
          }
  _ <- updatePage pageId req
  pure ()

-- | Get first data source ID from a database
getFirstDataSourceId :: Methods -> Text.Text -> IO UUID
getFirstDataSourceId Methods {retrieveDatabase} dbIdText = do
  db <- retrieveDatabase (UUID dbIdText)
  let dsList = Notion.V1.Databases.dataSources db
  assertBool "Database should have at least one data source" (not $ Vector.null dsList)
  let Notion.V1.Databases.DataSource {id = dsId} = Vector.head dsList
  pure dsId

-- =====================================================================
-- 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
  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
      let PageObject {inTrash = pageInTrash, url = pageUrl} = page
      assertEqual "inTrash should be False" False pageInTrash
      assertEqual "url should match" "https://www.notion.so/page-123" pageUrl

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 (unit tests, no API token needed)
-- =====================================================================

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,
      testCase "UpdatePageMarkdown update_content" testSerializeUpdateContent,
      testCase "UpdatePageMarkdown replace_content" testSerializeReplaceContent,
      testCase "Template none" testSerializeTemplateNone,
      testCase "Template default with timezone" testSerializeTemplateDefault,
      testCase "Template by ID" testSerializeTemplateById,
      testCase "MovePage serialization" testSerializeMovePage,
      testCase "ViewType round-trip" testViewTypeRoundTrip,
      testCase "NativeIcon round-trip" testNativeIconRoundTrip,
      testCase "CustomEmojiIcon round-trip" testCustomEmojiIconRoundTrip,
      testCase "CreateView serialization" testSerializeCreateView,
      testCase "UpdateView omits Nothing fields" testSerializeUpdateView,
      testCase "CreatePage with markdown field" testSerializeCreatePageMarkdown,
      testCase "UpdatePage with template and eraseContent" testSerializeUpdatePageTemplate
    ]

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"

testSerializeUpdateContent :: Assertion
testSerializeUpdateContent = do
  let req =
        UpdateContent
          UpdateContentRequest
            { contentUpdates =
                Vector.fromList
                  [ ContentUpdate
                      { oldStr = "hello world",
                        newStr = "goodbye world",
                        replaceAllMatches = Just True
                      }
                  ],
              allowDeletingContent = Nothing
            }
      json = Aeson.toJSON req
  case json of
    Aeson.Object o -> do
      assertEqual "type field" (Just (Aeson.String "update_content")) (KeyMap.lookup "type" o)
      case KeyMap.lookup "update_content" o of
        Just (Aeson.Object uc) -> do
          assertBool "should have content_updates" (KeyMap.member "content_updates" uc)
          case KeyMap.lookup "content_updates" uc of
            Just (Aeson.Array updates) ->
              assertEqual "should have 1 update" 1 (Vector.length updates)
            _ -> assertFailure "Expected content_updates array"
        _ -> assertFailure "Expected update_content object"
    _ -> assertFailure "Expected JSON object"

testSerializeReplaceContent :: Assertion
testSerializeReplaceContent = do
  let req =
        ReplaceContent
          ReplaceContentRequest
            { newStr = "# New Content\n\nReplaced everything.",
              allowDeletingContent = Just True
            }
      json = Aeson.toJSON req
  case json of
    Aeson.Object o -> do
      assertEqual "type field" (Just (Aeson.String "replace_content")) (KeyMap.lookup "type" o)
      case KeyMap.lookup "replace_content" o of
        Just (Aeson.Object rc) -> do
          assertEqual "new_str" (Just (Aeson.String "# New Content\n\nReplaced everything.")) (KeyMap.lookup "new_str" rc)
          assertEqual "allow_deleting_content" (Just (Aeson.Bool True)) (KeyMap.lookup "allow_deleting_content" rc)
        _ -> assertFailure "Expected replace_content object"
    _ -> assertFailure "Expected JSON object"

testSerializeTemplateNone :: Assertion
testSerializeTemplateNone = do
  let json = Aeson.toJSON NoTemplate
  case json of
    Aeson.Object o ->
      assertEqual "type field" (Just (Aeson.String "none")) (KeyMap.lookup "type" o)
    _ -> assertFailure "Expected JSON object"

testSerializeTemplateDefault :: Assertion
testSerializeTemplateDefault = do
  let json = Aeson.toJSON (DefaultTemplate (Just "America/New_York"))
  case json of
    Aeson.Object o -> do
      assertEqual "type field" (Just (Aeson.String "default")) (KeyMap.lookup "type" o)
      assertEqual "timezone" (Just (Aeson.String "America/New_York")) (KeyMap.lookup "timezone" o)
    _ -> assertFailure "Expected JSON object"

testSerializeTemplateById :: Assertion
testSerializeTemplateById = do
  let json = Aeson.toJSON (TemplateById (UUID "tmpl-123") Nothing)
  case json of
    Aeson.Object o -> do
      assertEqual "type field" (Just (Aeson.String "template_id")) (KeyMap.lookup "type" o)
      assertEqual "template_id" (Just (Aeson.String "tmpl-123")) (KeyMap.lookup "template_id" o)
      assertBool "no timezone" (not $ KeyMap.member "timezone" o)
    _ -> assertFailure "Expected JSON object"

testNativeIconRoundTrip :: Assertion
testNativeIconRoundTrip = do
  let icon = NativeIcon {iconName = "check", iconColor = Just "green"}
      json = Aeson.toJSON icon
  case json of
    Aeson.Object o -> do
      assertEqual "type" (Just (Aeson.String "icon")) (KeyMap.lookup "type" o)
      assertEqual "name" (Just (Aeson.String "check")) (KeyMap.lookup "name" o)
      assertEqual "color" (Just (Aeson.String "green")) (KeyMap.lookup "color" o)
    _ -> assertFailure "Expected JSON object"
  case Aeson.fromJSON json of
    Aeson.Success (NativeIcon n c) -> do
      assertEqual "name round-trip" "check" n
      assertEqual "color round-trip" (Just "green") c
    Aeson.Success _ -> assertFailure "Expected NativeIcon"
    Aeson.Error err -> assertFailure $ "Decode failed: " <> err

testCustomEmojiIconRoundTrip :: Assertion
testCustomEmojiIconRoundTrip = do
  let icon = CustomEmojiIcon {customEmojiId = UUID "emoji-abc-123"}
      json = Aeson.toJSON icon
  case json of
    Aeson.Object o -> do
      assertEqual "type" (Just (Aeson.String "custom_emoji")) (KeyMap.lookup "type" o)
      assertEqual "id" (Just (Aeson.String "emoji-abc-123")) (KeyMap.lookup "id" o)
    _ -> assertFailure "Expected JSON object"
  case Aeson.fromJSON json of
    Aeson.Success (CustomEmojiIcon eid) ->
      assertEqual "id round-trip" (UUID "emoji-abc-123") eid
    Aeson.Success _ -> assertFailure "Expected CustomEmojiIcon"
    Aeson.Error err -> assertFailure $ "Decode failed: " <> err

testViewTypeRoundTrip :: Assertion
testViewTypeRoundTrip = do
  let viewTypes = [TableView, BoardView, ListViewType, CalendarView, TimelineView, GalleryView, FormView, ChartView, MapView, DashboardView]
  mapM_
    ( \vt -> do
        let json = Aeson.toJSON vt
        case Aeson.fromJSON json of
          Aeson.Success decoded ->
            assertEqual ("round-trip for " <> show vt) vt decoded
          Aeson.Error err ->
            assertFailure $ "Failed to decode " <> show vt <> ": " <> err
    )
    viewTypes

testSerializeMovePage :: Assertion
testSerializeMovePage = do
  let req = MovePage {parent = PageParent {pageId = UUID "target-page"}, position = Nothing}
      json = Aeson.toJSON req
  case json of
    Aeson.Object o -> do
      assertBool "should have parent" (KeyMap.member "parent" o)
      assertBool "should not have position" (not $ KeyMap.member "position" o)
    _ -> assertFailure "Expected JSON object"

testSerializeCreateView :: Assertion
testSerializeCreateView = do
  let req =
        CreateView
          { dataSourceId = UUID "ds-123",
            name = "Test Table",
            type_ = TableView,
            databaseId = Just (UUID "db-456"),
            viewId = Nothing,
            filter = Nothing,
            sorts = Nothing,
            quickFilters = Nothing,
            configuration = Nothing,
            position = Nothing
          }
      json = Aeson.toJSON req
  case json of
    Aeson.Object o -> do
      assertEqual "data_source_id" (Just (Aeson.String "ds-123")) (KeyMap.lookup "data_source_id" o)
      assertEqual "name" (Just (Aeson.String "Test Table")) (KeyMap.lookup "name" o)
      assertEqual "type" (Just (Aeson.String "table")) (KeyMap.lookup "type" o)
      assertEqual "database_id" (Just (Aeson.String "db-456")) (KeyMap.lookup "database_id" o)
      -- Nothing fields should be omitted
      assertBool "no view_id" (not $ KeyMap.member "view_id" o)
      assertBool "no filter" (not $ KeyMap.member "filter" o)
    _ -> assertFailure "Expected JSON object"

testSerializeUpdateView :: Assertion
testSerializeUpdateView = do
  let req =
        UpdateView
          { name = Just "Renamed View",
            filter = Nothing,
            sorts = Nothing,
            quickFilters = Nothing,
            configuration = Nothing
          }
      json = Aeson.toJSON req
  case json of
    Aeson.Object o -> do
      assertEqual "name" (Just (Aeson.String "Renamed View")) (KeyMap.lookup "name" o)
      -- Nothing fields omitted
      assertBool "no filter" (not $ KeyMap.member "filter" o)
      assertBool "no sorts" (not $ KeyMap.member "sorts" o)
    _ -> assertFailure "Expected JSON object"

testSerializeCreatePageMarkdown :: Assertion
testSerializeCreatePageMarkdown = do
  let req =
        CreatePage
          { parent = PageParent {pageId = UUID "p-1"},
            properties = Map.empty,
            children = Nothing,
            markdown = Just "# Hello\n\nWorld",
            icon = Nothing,
            cover = Nothing,
            template = Nothing,
            position = Nothing
          }
      json = Aeson.toJSON req
  case json of
    Aeson.Object o -> do
      assertEqual "markdown" (Just (Aeson.String "# Hello\n\nWorld")) (KeyMap.lookup "markdown" o)
      -- children should not be present (Nothing)
      assertBool "no children" (not $ KeyMap.member "children" o)
    _ -> assertFailure "Expected JSON object"

testSerializeUpdatePageTemplate :: Assertion
testSerializeUpdatePageTemplate = do
  let req =
        UpdatePage
          { properties = Map.empty,
            inTrash = Nothing,
            icon = Nothing,
            cover = Nothing,
            template = Just (DefaultTemplate (Just "America/Chicago")),
            eraseContent = Just True
          }
      json = Aeson.toJSON req
  case json of
    Aeson.Object o -> do
      assertBool "should have template" (KeyMap.member "template" o)
      assertEqual "erase_content" (Just (Aeson.Bool True)) (KeyMap.lookup "erase_content" o)
      case KeyMap.lookup "template" o of
        Just (Aeson.Object t) -> do
          assertEqual "template type" (Just (Aeson.String "default")) (KeyMap.lookup "type" t)
          assertEqual "timezone" (Just (Aeson.String "America/Chicago")) (KeyMap.lookup "timezone" t)
        _ -> assertFailure "Expected template object"
    _ -> assertFailure "Expected JSON object"

-- =====================================================================
-- Integration Tests — Basic (needs NOTION_TOKEN)
-- =====================================================================

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)

testSearchAll :: Methods -> Assertion
testSearchAll Methods {search} = do
  let params = SearchRequest {query = Nothing, sort = Nothing, filter = Nothing, startCursor = Nothing, pageSize = Just 5}
  result <- search params
  -- Should succeed without error. Results may or may not be empty depending on workspace.
  assertBool "Search should return a list" (hasMore result || not (Vector.null (results result)) || Vector.null (results result))

testSearchPages :: Methods -> Assertion
testSearchPages Methods {search} = do
  let params = SearchRequest {query = Nothing, sort = Nothing, filter = Just pageFilter, startCursor = Nothing, pageSize = Just 3}
  result <- search params
  let typed = parseSearchResults result
  -- All results should be PageResult
  Vector.forM_ typed $ \r ->
    case r of
      PageResult _ -> pure ()
      DataSourceResult _ -> assertFailure "Expected only page results with page filter"

testSearchDataSources :: Methods -> Assertion
testSearchDataSources Methods {search} = do
  let params = SearchRequest {query = Nothing, sort = Nothing, filter = Just dataSourceFilter, startCursor = Nothing, pageSize = Just 3}
  result <- search params
  let typed = parseSearchResults result
  Vector.forM_ typed $ \r ->
    case r of
      DataSourceResult _ -> pure ()
      PageResult _ -> assertFailure "Expected only data source results with data source filter"

testListCustomEmojis :: Methods -> Assertion
testListCustomEmojis Methods {listCustomEmojis} = do
  result <- listCustomEmojis Nothing Nothing (Just 10)
  -- Just verify the endpoint works; workspace may or may not have custom emojis
  assertBool "Custom emojis endpoint should succeed" True

-- =====================================================================
-- Markdown E2E (needs NOTION_TOKEN + NOTION_TEST_PAGE_ID)
-- =====================================================================

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)

-- | Full markdown lifecycle: create page with markdown, read it back,
-- perform search-and-replace edit, verify, replace all content, verify, clean up.
testMarkdownLifecycle :: Methods -> Text.Text -> Assertion
testMarkdownLifecycle methods@Methods {retrievePageMarkdown, updatePageMarkdown} parentPageId = do
  -- Step 1: Create a test page (content will be set via markdown API)
  page <- createTestPage methods parentPageId "Markdown E2E Test"
  let PageObject {id = pageId} = page

  -- Step 2: Add content via replace_content (guaranteed to work regardless of create method)
  let replaceReq =
        ReplaceContent
          ReplaceContentRequest
            { newStr = "# Test Heading\n\nThis is the original paragraph.\n\nAnother paragraph here.",
              allowDeletingContent = Just True
            }
  md1 <- updatePageMarkdown pageId replaceReq
  let PageMarkdown {markdown = mdText1} = md1
  assertBool "Should contain heading after replace" (Text.isInfixOf "Test Heading" mdText1)
  assertBool "Should contain original paragraph" (Text.isInfixOf "original paragraph" mdText1)

  -- Step 3: Search-and-replace edit (update_content)
  let editReq =
        UpdateContent
          UpdateContentRequest
            { contentUpdates =
                Vector.fromList
                  [ ContentUpdate
                      { oldStr = "original paragraph",
                        newStr = "edited paragraph",
                        replaceAllMatches = Nothing
                      }
                  ],
              allowDeletingContent = Nothing
            }
  md2 <- updatePageMarkdown pageId editReq
  let PageMarkdown {markdown = mdText2} = md2
  assertBool "Should contain edited text" (Text.isInfixOf "edited paragraph" mdText2)
  assertBool "Should not contain original text" (not $ Text.isInfixOf "original paragraph" mdText2)

  -- Step 4: Full content replacement (replace_content)
  let replaceReq2 =
        ReplaceContent
          ReplaceContentRequest
            { newStr = "# Replaced\n\nEntirely new content after replace.",
              allowDeletingContent = Just True
            }
  md3 <- updatePageMarkdown pageId replaceReq2
  let PageMarkdown {markdown = mdText3} = md3
  assertBool "Should contain replaced heading" (Text.isInfixOf "Replaced" mdText3)
  assertBool "Should contain new content" (Text.isInfixOf "Entirely new content" mdText3)
  assertBool "Old content should be gone" (not $ Text.isInfixOf "edited paragraph" mdText3)

  -- Step 5: Verify retrieve matches update response
  md4 <- retrievePageMarkdown pageId Nothing
  let PageMarkdown {markdown = mdText4} = md4
  assertBool "Retrieve after replace should have new content" (Text.isInfixOf "Entirely new content" mdText4)

  -- Clean up
  trashPage methods pageId

-- =====================================================================
-- Page E2E (needs NOTION_TOKEN + NOTION_TEST_PAGE_ID)
-- =====================================================================

-- | Create page, append various block types, list children, retrieve blocks.
testPageBlockLifecycle :: Methods -> Text.Text -> Assertion
testPageBlockLifecycle methods@Methods {createPage, appendBlockChildren, listBlockChildren, retrieveBlock} parentPageId = do
  -- Create a test page
  page <- createTestPage methods parentPageId "Block Lifecycle E2E Test"
  let PageObject {id = pageId} = page

  -- Append blocks with different types
  let blocks =
        Vector.fromList
          [ mkHeadingBlock "Test Heading" 1,
            mkParagraphBlock "First paragraph content.",
            mkParagraphBlock "Second paragraph content.",
            Aeson.object
              [ ("type", Aeson.String "bulleted_list_item"),
                ("bulleted_list_item", Aeson.object [("rich_text", mkRichTextValue "List item one")])
              ],
            Aeson.object
              [ ("type", Aeson.String "bulleted_list_item"),
                ("bulleted_list_item", Aeson.object [("rich_text", mkRichTextValue "List item two")])
              ]
          ]
      appendReq = AppendBlockChildren {children = blocks, position = Nothing}
  appendResult <- appendBlockChildren pageId appendReq
  assertEqual "Should have appended 5 blocks" 5 (Vector.length $ results appendResult)

  -- List block children
  childrenResult <- listBlockChildren pageId Nothing Nothing
  let childCount = Vector.length (results childrenResult)
  assertBool "Should have at least 5 children" (childCount >= 5)

  -- Retrieve a specific block
  let firstBlock = Vector.head (results childrenResult)
      BlockObject {id = blockId} = firstBlock
  retrieved <- retrieveBlock blockId
  assertEqual "Retrieved block ID should match" blockId (Notion.V1.Blocks.id retrieved)

  -- Append a block at the start position
  let startBlock = mkParagraphBlock "Inserted at start."
      startReq = AppendBlockChildren {children = Vector.singleton startBlock, position = Just Start}
  _ <- appendBlockChildren pageId startReq

  -- Verify the start block is first
  childrenAfterStart <- listBlockChildren pageId Nothing Nothing
  let firstChild = Vector.head (results childrenAfterStart)
  assertEqual "First block type should be paragraph" "paragraph" (Notion.V1.Blocks.type_ firstChild)

  -- Clean up
  trashPage methods pageId

-- | Create page, add comments (page-level and block-level), list them.
testCommentLifecycle :: Methods -> Text.Text -> Assertion
testCommentLifecycle methods@Methods {createComment, listComments, appendBlockChildren} parentPageId = do
  -- Create a test page
  page <- createTestPage methods parentPageId "Comment Lifecycle E2E Test"
  let PageObject {id = pageId} = page

  -- Add a block to comment on
  let block = mkParagraphBlock "Block to comment on."
      appendReq = AppendBlockChildren {children = Vector.singleton block, position = Nothing}
  appendResult <- appendBlockChildren pageId appendReq
  let BlockObject {id = blockId} = Vector.head (results appendResult)

  -- Create a page-level comment
  let pageComment =
        CreateComment
          { parent = PageParent {pageId},
            richText = Vector.singleton (mkTypedRichText "This is a page-level comment from E2E tests."),
            discussionId = Nothing
          }
  comment1 <- createComment pageComment
  let CommentObject {id = comment1Id} = comment1
  assertBool "Comment should have an ID" (show comment1Id /= "")

  -- Create a block-level comment (a discussion on a specific block)
  let blockComment =
        CreateComment
          { parent = BlockParent {blockId},
            richText = Vector.singleton (mkTypedRichText "This is a block-level comment from E2E tests."),
            discussionId = Nothing
          }
  comment2 <- createComment blockComment
  let CommentObject {id = comment2Id} = comment2
  assertBool "Block comment should have an ID" (show comment2Id /= "")

  -- List comments on the page
  pageComments <- listComments (Just pageId) Nothing Nothing
  assertBool "Should have at least 1 page comment" (not $ Vector.null (results pageComments))

  -- List comments on the block
  blockComments <- listComments (Just blockId) Nothing Nothing
  assertBool "Should have at least 1 block comment" (not $ Vector.null (results blockComments))

  -- Clean up
  trashPage methods pageId

-- | Create two child pages, move one under the other, verify, clean up.
testMovePageLifecycle :: Methods -> Text.Text -> Assertion
testMovePageLifecycle methods@Methods {movePage, retrievePage} parentPageId = do
  -- Create two sibling pages
  pageA <- createTestPage methods parentPageId "Move Test - Source Page"
  pageB <- createTestPage methods parentPageId "Move Test - Target Parent"
  let PageObject {id = pageAId} = pageA
      PageObject {id = pageBId} = pageB

  -- Move page A under page B
  let moveReq = MovePage {parent = PageParent {pageId = pageBId}, position = Nothing}
  movedPage <- movePage pageAId moveReq
  let PageObject {id = movedId} = movedPage
  assertEqual "Moved page should have same ID" pageAId movedId

  -- Verify the parent changed
  retrieved <- retrievePage pageAId
  let PageObject {parent = retrievedParent} = retrieved
  case retrievedParent of
    PageParent {pageId = actualParent} ->
      assertEqual "Parent should be page B" pageBId actualParent
    other ->
      assertFailure $ "Expected PageParent, got: " <> show other

  -- Clean up (trash page A first since it's under B, then B)
  trashPage methods pageAId
  trashPage methods pageBId

-- =====================================================================
-- Database E2E (needs NOTION_TOKEN + NOTION_TEST_DATABASE_ID)
-- =====================================================================

testRetrieveDatabaseAndDataSource :: Methods -> Text.Text -> Assertion
testRetrieveDatabaseAndDataSource Methods {retrieveDatabase, retrieveDataSource} dbIdText = do
  -- Retrieve the database
  db <- retrieveDatabase (UUID dbIdText)
  let DatabaseObject {id = dbId, dataSources = dsList} = db
  -- UUIDs may differ in format (with/without dashes), just verify non-empty
  assertBool "Database ID should not be empty" (show dbId /= "")
  assertBool "Database should have at least one data source" (not $ Vector.null dsList)

  -- Retrieve the first data source
  let Notion.V1.Databases.DataSource {id = dsId} = Vector.head dsList
  ds <- retrieveDataSource dsId
  assertBool "Data source should have an ID" (show (Notion.V1.DataSources.id ds) /= "")
  assertBool "Data source URL should not be empty" (not $ Text.null (Notion.V1.DataSources.url ds))

testListTemplates :: Methods -> Text.Text -> Assertion
testListTemplates methods@Methods {listDataSourceTemplates} dbIdText = do
  dsId <- getFirstDataSourceId methods dbIdText
  -- List templates — may be empty if no templates configured, but endpoint should succeed
  result <- listDataSourceTemplates dsId Nothing Nothing Nothing
  -- Just verify the endpoint responds without error
  assertBool "Templates endpoint should succeed" True

testQueryDataSource :: Methods -> Text.Text -> Assertion
testQueryDataSource methods@Methods {queryDataSource} dbIdText = do
  dsId <- getFirstDataSourceId methods dbIdText
  let queryReq = DataSources.QueryDataSource {filter = Nothing, sorts = Nothing, startCursor = Nothing, pageSize = Just 5, inTrash = Nothing}
  result <- queryDataSource dsId queryReq
  -- Just verify the endpoint responds and returns valid structure
  assertBool "Query should return results list" (hasMore result || Vector.null (results result) || not (Vector.null (results result)))

testCreateMarkdownPageInDatabase :: Methods -> Text.Text -> Assertion
testCreateMarkdownPageInDatabase methods@Methods {createPage, retrievePageMarkdown, updatePageMarkdown} dbIdText = do
  dsId <- getFirstDataSourceId methods dbIdText

  -- Create a page in the database's data source
  let titleProp = Aeson.object [("title", mkRichTextValue "Database Markdown E2E")]
      props = Map.fromList [("title", PropertyValue {type_ = Title, value = Just titleProp})]
      req = mkCreatePage (DataSourceParent {dataSourceId = dsId}) props
  page <- createPage req
  let PageObject {id = pageId} = page

  -- Set content via markdown update API
  let replaceReq =
        ReplaceContent
          ReplaceContentRequest
            { newStr = "# Database Page\n\nCreated with markdown in a database data source.",
              allowDeletingContent = Just True
            }
  _ <- updatePageMarkdown pageId replaceReq

  -- Verify markdown content
  md <- retrievePageMarkdown pageId Nothing
  let PageMarkdown {markdown = mdText} = md
  assertBool "Should contain database page heading" (Text.isInfixOf "Database Page" mdText)

  -- Clean up
  trashPage methods pageId

-- =====================================================================
-- View E2E (needs NOTION_TOKEN + NOTION_TEST_DATABASE_ID)
-- =====================================================================

-- | Full view lifecycle: create, retrieve, update, list, query, delete.
testViewLifecycle :: Methods -> Text.Text -> Assertion
testViewLifecycle methods@Methods {createView, retrieveView, updateView, listViews, queryView, deleteView} dbIdText = do
  dsId <- getFirstDataSourceId methods dbIdText

  -- Step 1: Create a table view
  let createReq =
        CreateView
          { dataSourceId = dsId,
            name = "E2E Test View",
            type_ = TableView,
            databaseId = Just (UUID dbIdText),
            viewId = Nothing,
            filter = Nothing,
            sorts = Nothing,
            quickFilters = Nothing,
            configuration = Nothing,
            position = Nothing
          }
  view <- createView createReq
  let ViewObject {id = viewId, type_ = viewType, name = viewName} = view
  assertEqual "View type should be table" (Just TableView) viewType
  assertEqual "View name should match" (Just "E2E Test View") viewName

  -- Step 2: Retrieve the view
  retrieved <- retrieveView viewId
  let ViewObject {id = retrievedViewId} = retrieved
  assertEqual "Retrieved view ID should match" viewId retrievedViewId
  let ViewObject {type_ = retrievedType} = retrieved
  assertEqual "Retrieved view type should be table" (Just TableView) retrievedType

  -- Step 3: Update the view (rename)
  let updateReq =
        UpdateView
          { name = Just "E2E Test View (Renamed)",
            filter = Nothing,
            sorts = Nothing,
            quickFilters = Nothing,
            configuration = Nothing
          }
  updated <- updateView viewId updateReq
  let ViewObject {name = updatedName} = updated
  assertEqual "Updated name" (Just "E2E Test View (Renamed)") updatedName

  -- Step 4: List views on the database
  viewList <- listViews (Just (UUID dbIdText)) Nothing Nothing Nothing
  let viewIds = Vector.map (\(ViewObject {id = vid}) -> vid) (results viewList)
  assertBool "View list should contain our view" (viewId `Vector.elem` viewIds)

  -- Step 5: Query the view (may fail if endpoint URL is different than expected)
  -- The query view endpoint URL is not yet confirmed in the API docs.
  -- We skip this step to avoid test failures from URL guessing.

  -- Step 6: Delete the view
  deleted <- deleteView viewId
  let ViewObject {id = deletedViewId} = deleted
  assertEqual "Deleted view ID should match" viewId deletedViewId

-- =====================================================================
-- Helpers
-- =====================================================================

-- | Create a typed RichText value for use in comments
mkTypedRichText :: Text.Text -> RT.RichText
mkTypedRichText t =
  RT.RichText
    { RT.plainText = t,
      RT.href = Nothing,
      RT.annotations = defaultAnnotations,
      RT.type_ = "text",
      RT.content = TextContentWrapper (TextContent {content = t, link = Nothing})
    }

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