notion-client 0.2.0.0 → 0.3.0.0
raw patch · 9 files changed
+297/−61 lines, 9 filesdep ~bytestringdep ~vectorPVP ok
version bump matches the API change (PVP)
Dependency ranges changed: bytestring, vector
API changes (from Hackage documentation)
- Notion.V1.Blocks: [archived] :: BlockObject -> Bool
- Notion.V1.Blocks: newtype AppendBlockChildren
- Notion.V1.DataSources: [archived] :: QueryDataSource -> Maybe Bool
- Notion.V1.Databases: [archived] :: UpdateDatabase -> Maybe Bool
- Notion.V1.Pages: [archived] :: UpdatePage -> Maybe Bool
+ Notion.V1.Blocks: AfterBlock :: UUID -> Position
+ Notion.V1.Blocks: End :: Position
+ Notion.V1.Blocks: Start :: Position
+ Notion.V1.Blocks: [inTrash] :: BlockObject -> Bool
+ Notion.V1.Blocks: [position] :: AppendBlockChildren -> Maybe Position
+ Notion.V1.Blocks: data AppendBlockChildren
+ Notion.V1.Blocks: data Position
+ Notion.V1.Blocks: instance Data.Aeson.Types.ToJSON.ToJSON Notion.V1.Blocks.Position
+ Notion.V1.Blocks: instance GHC.Internal.Generics.Generic Notion.V1.Blocks.Position
+ Notion.V1.Blocks: instance GHC.Internal.Show.Show Notion.V1.Blocks.Position
- Notion.V1.Blocks: AppendBlockChildren :: Vector Value -> AppendBlockChildren
+ Notion.V1.Blocks: AppendBlockChildren :: Vector Value -> Maybe Position -> AppendBlockChildren
- Notion.V1.DataSources: DataSourceObject :: DataSourceID -> POSIXTime -> POSIXTime -> UserReference -> UserReference -> Vector RichText -> Vector RichText -> Value -> Text -> Parent -> Maybe Parent -> Maybe Bool -> Maybe Bool -> Maybe Bool -> Maybe Text -> Maybe Icon -> Maybe Cover -> ObjectType -> DataSourceObject
+ Notion.V1.DataSources: DataSourceObject :: DataSourceID -> POSIXTime -> POSIXTime -> UserReference -> UserReference -> Vector RichText -> Vector RichText -> Value -> Text -> Parent -> Maybe Parent -> Maybe Bool -> Maybe Bool -> Maybe Text -> Maybe Icon -> Maybe Cover -> ObjectType -> DataSourceObject
- Notion.V1.DataSources: QueryDataSource :: Maybe Value -> Maybe [Value] -> Maybe Text -> Maybe Natural -> Maybe Bool -> Maybe Bool -> QueryDataSource
+ Notion.V1.DataSources: QueryDataSource :: Maybe Value -> Maybe [Value] -> Maybe Text -> Maybe Natural -> Maybe Bool -> QueryDataSource
- Notion.V1.DataSources: UpdateDataSource :: Maybe (Vector RichText) -> Maybe Icon -> Maybe Value -> Maybe Bool -> Maybe Bool -> Maybe Parent -> UpdateDataSource
+ Notion.V1.DataSources: UpdateDataSource :: Maybe (Vector RichText) -> Maybe Icon -> Maybe Value -> Maybe Bool -> Maybe Parent -> UpdateDataSource
- Notion.V1.Databases: DatabaseObject :: DatabaseID -> POSIXTime -> POSIXTime -> Maybe UserReference -> Maybe UserReference -> Vector RichText -> Maybe (Vector RichText) -> Maybe Value -> Maybe Icon -> Maybe Cover -> Text -> Parent -> Maybe Bool -> Maybe Bool -> Maybe Bool -> Maybe Bool -> Maybe Text -> Vector DataSource -> ObjectType -> DatabaseObject
+ Notion.V1.Databases: DatabaseObject :: DatabaseID -> POSIXTime -> POSIXTime -> Maybe UserReference -> Maybe UserReference -> Vector RichText -> Maybe (Vector RichText) -> Maybe Value -> Maybe Icon -> Maybe Cover -> Text -> Parent -> Maybe Bool -> Maybe Bool -> Maybe Bool -> Maybe Text -> Vector DataSource -> ObjectType -> DatabaseObject
- Notion.V1.Databases: UpdateDatabase :: Maybe (Vector RichText) -> Maybe Icon -> Maybe Cover -> Maybe (Vector RichText) -> Maybe Bool -> Maybe Bool -> Maybe Bool -> Maybe Parent -> UpdateDatabase
+ Notion.V1.Databases: UpdateDatabase :: Maybe (Vector RichText) -> Maybe Icon -> Maybe Cover -> Maybe (Vector RichText) -> Maybe Bool -> Maybe Bool -> Maybe Parent -> UpdateDatabase
- Notion.V1.Pages: PageObject :: PageID -> POSIXTime -> POSIXTime -> UserReference -> UserReference -> Maybe Cover -> Maybe Icon -> Parent -> Bool -> Bool -> Map Text PropertyItem -> Text -> ObjectType -> PageObject
+ Notion.V1.Pages: PageObject :: PageID -> POSIXTime -> POSIXTime -> UserReference -> UserReference -> Maybe Cover -> Maybe Icon -> Parent -> Bool -> Map Text PropertyItem -> Text -> ObjectType -> PageObject
- Notion.V1.Pages: [inTrash] :: PageObject -> Bool
+ Notion.V1.Pages: [inTrash] :: UpdatePage -> Maybe Bool
Files
- CHANGELOG.md +20/−0
- notion-client-example/DatabaseDemo.hs +1/−3
- notion-client-example/PageDemo.hs +1/−1
- notion-client.cabal +3/−1
- src/Notion/V1/Blocks.hs +42/−8
- src/Notion/V1/DataSources.hs +1/−5
- src/Notion/V1/Databases.hs +1/−4
- src/Notion/V1/Pages.hs +3/−7
- tasty/Main.hs +225/−32
CHANGELOG.md view
@@ -1,5 +1,25 @@ # Changelog for notion-client +## 0.3.0.0 (2026-03-29)++### Breaking Changes+* Remove `archived` field from `BlockObject` — use `inTrash` instead (renamed to match API 2026-03-11)+* Remove `archived` field from `PageObject` — use existing `inTrash` field+* Remove `archived` field from `DatabaseObject` — use existing `inTrash` field+* Remove `archived` field from `DataSourceObject` — use existing `inTrash` field+* Rename `archived` to `inTrash` in `UpdatePage` request type+* Remove `archived` from `UpdateDatabase` request type — use existing `inTrash` field+* Remove `archived` from `UpdateDataSource` request type — use existing `inTrash` field+* Remove `archived` from `QueryDataSource` request type — use existing `inTrash` field+* Change `AppendBlockChildren` from `newtype` to `data` with new optional `position` field++### New Features+* Add `Position` type (`AfterBlock`, `Start`, `End`) for specifying block insertion position+* The `transcription` block type is renamed to `meeting_notes` by the API (no library code change needed since block types are `Text`)++### Bug Fixes+* Fix backward-compatible `FromJSON` fallback: `.:?` with `<|>` was silently broken (always returned `Nothing`), now correctly falls back through `in_trash` → `is_archived` → `archived`+ ## 0.2.0.0 (2026-03-29) ### Breaking Changes
notion-client-example/DatabaseDemo.hs view
@@ -65,7 +65,6 @@ sorts = Nothing, startCursor = Nothing, pageSize = Just 5,- archived = Nothing, inTrash = Nothing } dsResults <-@@ -157,7 +156,6 @@ icon = Nothing, properties = Just combinedProperties, inTrash = Nothing,- archived = Nothing, parent = Nothing } @@ -260,7 +258,7 @@ createBulletedListItemBlock "Add rich content to pages", createBulletedListItemBlock "Query and retrieve data" ]- appendRequest = Blocks.AppendBlockChildren {children = additionalBlocks}+ appendRequest = Blocks.AppendBlockChildren {children = additionalBlocks, position = Nothing} -- Add blocks to the page _updatedPage <-
notion-client-example/PageDemo.hs view
@@ -144,7 +144,7 @@ ) ] specializedBlocks = Vector.fromList [codeBlock, quoteBlock, calloutBlock]- appendRequest = Blocks.AppendBlockChildren {children = specializedBlocks}+ appendRequest = Blocks.AppendBlockChildren {children = specializedBlocks, position = Nothing} -- Append blocks to the existing page _updatedPage <-
notion-client.cabal view
@@ -1,6 +1,6 @@ cabal-version: 3.4 name: notion-client-version: 0.2.0.0+version: 0.3.0.0 synopsis: Type-safe Haskell client for the Notion API description: This package provides comprehensive and type-safe bindings@@ -93,6 +93,7 @@ build-depends: , aeson , base+ , bytestring , http-client , http-client-tls , notion-client@@ -100,6 +101,7 @@ , tasty , tasty-hunit , text+ , vector ghc-options: -Wall
src/Notion/V1/Blocks.hs view
@@ -5,6 +5,7 @@ BlockObject (..), BlockContent (..), AppendBlockChildren (..),+ Position (..), -- * Servant API,@@ -15,9 +16,8 @@ import Data.Aeson ((.:), (.:?), (.=)) import Data.Aeson qualified as Aeson import Data.Aeson.Key qualified as Key-import Data.Maybe (fromMaybe) import Notion.Prelude-import Notion.V1.Common (BlockID, ObjectType (..), Parent)+import Notion.V1.Common (BlockID, ObjectType (..), Parent, UUID (..)) import Notion.V1.ListOf (ListOf) import Notion.V1.Users (UserReference) import Prelude hiding (id)@@ -31,7 +31,7 @@ createdBy :: UserReference, lastEditedBy :: UserReference, hasChildren :: Bool,- archived :: Bool,+ inTrash :: Bool, type_ :: Text, content :: Value, object :: ObjectType@@ -50,7 +50,7 @@ createdBy <- o .: "created_by" lastEditedBy <- o .: "last_edited_by" hasChildren <- o .: "has_children"- archived <- fmap (fromMaybe False) (o .:? "is_archived" <|> o .:? "archived")+ inTrash <- (o .: "in_trash") <|> (o .: "is_archived") <|> (o .: "archived") <|> pure False type_ <- o .: "type" -- Content is stored under a field named after the block type (e.g., "heading_1", "paragraph") content <- o .: Key.fromText type_@@ -68,7 +68,7 @@ "created_by" .= createdBy, "last_edited_by" .= lastEditedBy, "has_children" .= hasChildren,- "archived" .= archived,+ "in_trash" .= inTrash, "type" .= type_, Key.fromText type_ .= content, "object" .= object@@ -83,14 +83,48 @@ instance ToJSON BlockContent where toJSON = genericToJSON aesonOptions +-- | Block insertion position for the Append Block Children endpoint.+--+-- In API version 2026-03-11, the old @after@ parameter was replaced by a+-- @position@ object supporting three placement types.+data Position+ = -- | Insert after a specific block (replaces the old @after@ parameter)+ AfterBlock UUID+ | -- | Insert at the beginning of the parent+ Start+ | -- | Insert at the end of the parent (the default when @position@ is omitted)+ End+ deriving stock (Generic, Show)++instance ToJSON Position where+ toJSON (AfterBlock blockId) =+ Aeson.object+ [ "type" .= ("after_block" :: Text),+ "after_block" .= Aeson.object ["id" .= text blockId]+ ]+ toJSON Start =+ Aeson.object+ [ "type" .= ("start" :: Text),+ "start" .= Aeson.object []+ ]+ toJSON End =+ Aeson.object+ [ "type" .= ("end" :: Text),+ "end" .= Aeson.object []+ ]+ -- | Append children to a block-newtype AppendBlockChildren = AppendBlockChildren- { children :: Vector Value+data AppendBlockChildren = AppendBlockChildren+ { children :: Vector Value,+ position :: Maybe Position } deriving stock (Generic, Show) instance ToJSON AppendBlockChildren where- toJSON = genericToJSON aesonOptions+ toJSON AppendBlockChildren {..} =+ Aeson.object $+ ["children" .= children]+ <> maybe [] (\p -> ["position" .= p]) position -- | Servant API type API =
src/Notion/V1/DataSources.hs view
@@ -41,7 +41,6 @@ url :: Text, parent :: Parent, databaseParent :: Maybe Parent,- archived :: Maybe Bool, isInline :: Maybe Bool, inTrash :: Maybe Bool, publicUrl :: Maybe Text,@@ -67,9 +66,8 @@ url <- o .: "url" parent <- o .: "parent" databaseParent <- o .:? "database_parent"- archived <- o .:? "is_archived" <|> o .:? "archived" isInline <- o .:? "is_inline"- inTrash <- o .:? "in_trash"+ inTrash <- (fmap Just (o .: "in_trash")) <|> (fmap Just (o .: "is_archived")) <|> (fmap Just (o .: "archived")) <|> pure Nothing publicUrl <- o .:? "public_url" icon <- o .:? "icon" cover <- o .:? "cover"@@ -95,7 +93,6 @@ icon :: Maybe Icon, properties :: Maybe Value, inTrash :: Maybe Bool,- archived :: Maybe Bool, parent :: Maybe Parent } deriving stock (Generic, Show)@@ -109,7 +106,6 @@ sorts :: Maybe [Value], startCursor :: Maybe Text, pageSize :: Maybe Natural,- archived :: Maybe Bool, inTrash :: Maybe Bool } deriving stock (Generic, Show)
src/Notion/V1/Databases.hs view
@@ -55,7 +55,6 @@ cover :: Maybe Cover, url :: Text, parent :: Parent,- archived :: Maybe Bool, isInline :: Maybe Bool, inTrash :: Maybe Bool, isLocked :: Maybe Bool,@@ -82,9 +81,8 @@ cover <- o .:? "cover" url <- o .: "url" parent <- o .: "parent"- archived <- o .:? "is_archived" <|> o .:? "archived" isInline <- o .:? "is_inline"- inTrash <- o .:? "in_trash"+ inTrash <- (fmap Just (o .: "in_trash")) <|> (fmap Just (o .: "is_archived")) <|> (fmap Just (o .: "archived")) <|> pure Nothing isLocked <- o .:? "is_locked" publicUrl <- o .:? "public_url" dataSources <- o .: "data_sources"@@ -130,7 +128,6 @@ icon :: Maybe Icon, cover :: Maybe Cover, description :: Maybe (Vector RichText),- archived :: Maybe Bool, isInline :: Maybe Bool, inTrash :: Maybe Bool, parent :: Maybe Parent
src/Notion/V1/Pages.hs view
@@ -26,7 +26,6 @@ import Data.Aeson qualified as Aeson import Data.Aeson.Key qualified as Key import Data.Aeson.KeyMap qualified as KeyMap-import Data.Maybe (fromMaybe) import Notion.Prelude hiding (Number) import Notion.V1.Common (Cover, Icon, ObjectType (..), Parent, UUID) import Notion.V1.Users (UserReference)@@ -44,7 +43,6 @@ cover :: Maybe Cover, icon :: Maybe Icon, parent :: Parent,- archived :: Bool, inTrash :: Bool, properties :: Map Text PropertyItem, url :: Text,@@ -65,8 +63,7 @@ cover <- o .:? "cover" icon <- o .:? "icon" parent <- o .: "parent"- archived <- fmap (fromMaybe False) (o .:? "is_archived" <|> o .:? "archived")- inTrash <- o .: "in_trash"+ inTrash <- (o .: "in_trash") <|> (o .: "is_archived") <|> (o .: "archived") <|> pure False properties <- o .: "properties" url <- o .: "url" object <- o .: "object"@@ -84,7 +81,6 @@ "cover" .= cover, "icon" .= icon, "parent" .= parent,- "archived" .= archived, "in_trash" .= inTrash, "properties" .= properties, "url" .= url,@@ -118,7 +114,7 @@ -- | Update a page request data UpdatePage = UpdatePage { properties :: PageProperties,- archived :: Maybe Bool,+ inTrash :: Maybe Bool, icon :: Maybe Icon, cover :: Maybe Cover }@@ -132,7 +128,7 @@ mkUpdatePage properties = UpdatePage { properties,- archived = Nothing,+ inTrash = Nothing, icon = Nothing, cover = Nothing }
tasty/Main.hs view
@@ -1,10 +1,17 @@ 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 (..))+import Notion.V1.Pages (PageMarkdown (..), PageObject (..)) import Notion.V1.Search (SearchRequest (..)) import Notion.V1.Users (UserObject (..)) import System.Environment qualified as Environment@@ -13,50 +20,237 @@ main :: IO () main = do- -- These tests expect a NOTION_TOKEN environment variable- -- and only run against the actual Notion API defaultMain =<< tests tests :: IO TestTree tests = do mToken <- lookupEnv "NOTION_TOKEN" - case mToken of- Nothing ->- pure $- testGroup- "Notion API Tests"- [ testCase "No API token found" $- assertFailure "Set NOTION_TOKEN environment variable to run tests"- ]- Just token -> do- clientEnv <- getClientEnv "https://api.notion.com/v1"- let methods = makeMethods clientEnv (Text.pack token)-- mPageId <- lookupEnv "NOTION_TEST_PAGE_ID"+ 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 - let markdownTests = case mPageId of- Just pageId ->- [ testCase "Retrieve page as markdown" $- testRetrievePageMarkdown methods (Text.pack pageId)- ]- Nothing -> []+ mPageId <- lookupEnv "NOTION_TEST_PAGE_ID" - pure $- testGroup- "Notion API Tests"- ( [ testCase "Retrieve current user" $ testRetrieveCurrentUser methods,- testCase "List users" $ testListUsers methods,- testCase "Timestamp parsing works" $ testSearchAPI methods+ 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) ]- <> markdownTests- )+ _ ->+ 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- -- Using Show instance of UUID to verify it's not empty assertBool "User object should have an ID" (show userId /= "") testListUsers :: Methods -> Assertion@@ -76,7 +270,6 @@ pageSize = Nothing } _ <- search searchParams- -- If this test runs without errors, it means timestamp parsing works assertBool "Search should run without timestamp parsing errors" True testRetrievePageMarkdown :: Methods -> Text.Text -> Assertion