packages feed

notion-client-0.8.0.0: tasty/MeetingNotesTests.hs

-- | Meeting-notes create and query endpoints (EP-3).
module MeetingNotesTests (tests) where

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.Text (Text)
import Data.Vector qualified as Vector
import Notion.V1.Common (UUID (..))
import Notion.V1.Filter (SortDirection (..))
import Notion.V1.MeetingNotes
import Notion.V1.RichText (RichText (..))
import Test.Tasty
import Test.Tasty.HUnit

tests :: TestTree
tests =
  testGroup
    "Meeting notes (EP-3)"
    [ testCase "Decode full meeting note block" testDecodeFull,
      testCase "Decode partial create response" testDecodePartial,
      testCase "Unknown meeting-notes status is tolerated" testUnknownStatus,
      testCase "Minimal payload decodes" testMinimalPayload,
      testCase "CreateMeetingNote from file upload" testCreateFromFileUpload,
      testCase "CreateMeetingNote from block has no parent" testCreateFromBlock,
      testCase "Language codes" testLanguageCodes,
      testCase "Empty query encodes to {}" testEmptyQuery,
      testCase "Attendees filter matches the JS SDK test" testAttendeesFilter,
      testCase "Nested combinators, title and date point" testNestedFilter,
      testCase "Date range condition" testDateRange,
      testCase "Sort and limit" testSortAndLimit,
      testCase "Raw node passes through" testRawNode,
      testCase "Decode query response" testDecodeQueryResponse
    ]

blockWithPayload :: LBS.ByteString -> LBS.ByteString
blockWithPayload payload =
  "{\"object\":\"block\",\"id\":\"7e3f0a1b-0000-4000-8000-000000000101\",\"type\":\"meeting_notes\",\
  \\"meeting_notes\":"
    <> payload
    <> ",\"created_time\":\"2026-09-14T00:00:00.000Z\",\"last_edited_time\":\"2026-09-14T00:40:00.000Z\",\
       \\"created_by\":{\"object\":\"user\",\"id\":\"9a8b7c6d-0000-4000-8000-00000000000c\"},\
       \\"last_edited_by\":{\"object\":\"user\",\"id\":\"9a8b7c6d-0000-4000-8000-00000000000c\"},\
       \\"has_children\":true,\"in_trash\":false,\"archived\":false}"

fullPayload :: LBS.ByteString
fullPayload =
  "{\"title\":[{\"type\":\"text\",\"text\":{\"content\":\"Weekly sync\",\"link\":null},\
  \\"annotations\":{\"bold\":false,\"italic\":false,\"strikethrough\":false,\"underline\":false,\"code\":false,\"color\":\"default\"},\
  \\"plain_text\":\"Weekly sync\",\"href\":null}],\
  \\"status\":\"notes_ready\",\
  \\"children\":{\"summary_block_id\":\"7e3f0a1b-0000-4000-8000-000000000102\",\
  \\"notes_block_id\":\"7e3f0a1b-0000-4000-8000-000000000103\",\
  \\"transcript_block_id\":\"7e3f0a1b-0000-4000-8000-000000000104\"},\
  \\"calendar_event\":{\"start_time\":\"2026-09-14T09:00:00.000+09:00\",\"end_time\":\"2026-09-14T09:30:00.000+09:00\",\
  \\"attendees\":[\"9a8b7c6d-0000-4000-8000-00000000000c\"]},\
  \\"recording\":{\"start_time\":\"2026-09-14T09:01:00.000+09:00\"}}"

-- | The meeting-note block fixture used by the create and query tests.
blockFixture :: LBS.ByteString
blockFixture = blockWithPayload fullPayload

decodeContent :: LBS.ByteString -> IO MeetingNotesContent
decodeContent bs = case Aeson.eitherDecode bs of
  Right (FullMeetingNote MeetingNoteBlock {meetingNotes}) -> pure meetingNotes
  Right other -> assertFailure ("expected FullMeetingNote, got " <> show other)
  Left err -> assertFailure err

decodeValue :: LBS.ByteString -> IO Value
decodeValue bs = either (assertFailure . ("fixture: " <>)) pure (Aeson.eitherDecode bs)

testDecodeFull :: Assertion
testDecodeFull = do
  MeetingNotesContent {contentTitle, contentStatus, contentCalendarEvent} <- decodeContent blockFixture
  contentStatus @?= Just NotesReady
  fmap (fmap (\RichText {plainText} -> plainText) . Vector.toList) contentTitle @?= Just ["Weekly sync"]
  fmap (\MeetingCalendarEvent {calendarAttendees} -> fmap Vector.length calendarAttendees) contentCalendarEvent
    @?= Just (Just 1)

testDecodePartial :: Assertion
testDecodePartial =
  case Aeson.eitherDecode "{\"object\":\"block\",\"id\":\"7e3f0a1b-0000-4000-8000-000000000101\"}" of
    Right (PartialMeetingNote bid) -> bid @?= UUID "7e3f0a1b-0000-4000-8000-000000000101"
    Right other -> assertFailure ("expected PartialMeetingNote, got " <> show other)
    Left err -> assertFailure err

testUnknownStatus :: Assertion
testUnknownStatus = do
  MeetingNotesContent {contentStatus = archiving} <- decodeContent (blockWithPayload "{\"status\":\"archiving\"}")
  archiving @?= Just (UnknownMeetingNotesStatus "archiving")
  MeetingNotesContent {contentStatus = failed} <- decodeContent (blockWithPayload "{\"status\":\"transcription_failed\"}")
  failed @?= Just TranscriptionFailed

testMinimalPayload :: Assertion
testMinimalPayload = do
  content <- decodeContent (blockWithPayload "{}")
  content @?= MeetingNotesContent Nothing Nothing Nothing Nothing Nothing

testCreateFromFileUpload :: Assertion
testCreateFromFileUpload = do
  let req =
        CreateMeetingNote
          { source =
              FromFileUpload
                (UUID "a02fc1d3-db8b-45c5-a222-27595b15aea7")
                (UUID "c02fc1d3-db8b-45c5-a222-27595b15aea7"),
            title = Just "Weekly sync",
            language = Just LanguageEn,
            kickoffSummary = Just True
          }
  expected <-
    decodeValue
      "{\"source\":{\"type\":\"file_upload\",\"file_upload_id\":\"a02fc1d3-db8b-45c5-a222-27595b15aea7\"},\
      \\"parent\":{\"type\":\"page_id\",\"page_id\":\"c02fc1d3-db8b-45c5-a222-27595b15aea7\"},\
      \\"title\":\"Weekly sync\",\"language\":\"en\",\"options\":{\"kickoff_summary\":true}}"
  Aeson.toJSON req @?= expected

testCreateFromBlock :: Assertion
testCreateFromBlock = do
  expected <- decodeValue "{\"source\":{\"type\":\"block\",\"block_id\":\"b-1\"}}"
  Aeson.toJSON (mkCreateMeetingNote (FromBlock (UUID "b-1"))) @?= expected

testLanguageCodes :: Assertion
testLanguageCodes =
  map Aeson.toJSON [LanguageZhCN, LanguageZhTW, LanguageNo, LanguageOther "tl"]
    @?= map Aeson.String ["zh-CN", "zh-TW", "no", "tl"]

-- | A value nested inside JSON objects, looked up by key path.
lookupPath :: [Aeson.Key] -> Value -> Maybe Value
lookupPath [] v = Just v
lookupPath (k : ks) (Aeson.Object o) = KeyMap.lookup k o >>= lookupPath ks
lookupPath _ _ = Nothing

-- | The encoded condition of a property filter node.
conditionOf :: MeetingNotesPropertyFilter -> Maybe Value
conditionOf = lookupPath ["filter"] . Aeson.toJSON

testEmptyQuery :: Assertion
testEmptyQuery = Aeson.toJSON emptyQueryMeetingNotes @?= Aeson.object []

testAttendeesFilter :: Assertion
testAttendeesFilter = do
  let QueryMeetingNotes {sort, limit} = emptyQueryMeetingNotes
      query =
        QueryMeetingNotes
          { filter = Just (mnAnd [mnAttendeesInclude (UUID "a1b2c3d4-e5f6-7890-abcd-ef1234567890")]),
            sort,
            limit
          }
  expected <-
    decodeValue
      "{\"filter\":{\"operator\":\"and\",\"filters\":[{\"property\":\"attendees\",\
      \\"filter\":{\"operator\":\"person_contains\",\"value\":[{\"type\":\"exact\",\
      \\"value\":{\"table\":\"notion_user\",\"id\":\"a1b2c3d4-e5f6-7890-abcd-ef1234567890\"}}]}}]}}"
  Aeson.toJSON query @?= expected

testNestedFilter :: Assertion
testNestedFilter = do
  let datePoint =
        MNProperty
          ( MNCreatedTime
              ( MNDateIsOnOrAfter
                  (MeetingNotesDatePoint MNExact (MNDatePointSpec (MeetingNotesDateSpec True "2026-09-01" (Just "09:30") (Just "Asia/Tokyo"))))
              )
          )
      f = mnOr [mnTitleContains "standup", MNNested (mnAnd [datePoint, MNProperty (MNTitle MNTextIsNotEmpty)])]
  title <- decodeValue "{\"property\":\"title\",\"filter\":{\"operator\":\"string_contains\",\"value\":{\"type\":\"exact\",\"value\":\"standup\"}}}"
  spec <- decodeValue "{\"type\":\"datetime\",\"start_date\":\"2026-09-01\",\"start_time\":\"09:30\",\"time_zone\":\"Asia/Tokyo\"}"
  notEmpty <- decodeValue "{\"property\":\"title\",\"filter\":{\"operator\":\"is_not_empty\"}}"
  let nested = Aeson.object ["operator" .= ("and" :: Text), "filters" .= [Aeson.toJSON datePoint, notEmpty]]
  Aeson.toJSON f @?= Aeson.object ["operator" .= ("or" :: Text), "filters" .= [title, nested]]
  lookupPath ["filter", "value", "value"] (Aeson.toJSON datePoint) @?= Just spec
  lookupPath ["filter", "operator"] (Aeson.toJSON datePoint) @?= Just (Aeson.String "date_is_on_or_after")

testDateRange :: Assertion
testDateRange = do
  relative <- decodeValue "{\"type\":\"relative\",\"value\":\"custom\",\"direction\":\"past\",\"unit\":\"week\",\"count\":2}"
  (conditionOf (MNLastEditedTime (MNDateIsWithin (MeetingNotesDateRange MNRelative (MNDateRangeText "custom") (Just MNPast) (Just MNWeek) (Just 2)))) >>= lookupPath ["value"])
    @?= Just relative
  -- mnCreatedWithinPast produces the shape Notion accepted in a live check
  within <- decodeValue "{\"operator\":\"date_is_within\",\"value\":{\"type\":\"relative\",\"value\":\"custom\",\"direction\":\"past\",\"unit\":\"year\",\"count\":1}}"
  lookupPath ["filter"] (Aeson.toJSON (mnCreatedWithinPast 1 MNYear)) @?= Just within
  exact <- decodeValue "{\"type\":\"exact\",\"value\":{\"type\":\"daterange\",\"start_date\":\"2026-09-01\"}}"
  (conditionOf (MNLastEditedTime (MNDateIsWithin (MeetingNotesDateRange MNExact (MNDateRangeSpec "2026-09-01" Nothing) Nothing Nothing Nothing))) >>= lookupPath ["value"])
    @?= Just exact

testSortAndLimit :: Assertion
testSortAndLimit = do
  expected <- decodeValue "{\"sort\":[{\"property\":\"created_time\",\"direction\":\"descending\"}],\"limit\":10}"
  Aeson.toJSON (QueryMeetingNotes Nothing (Just [MeetingNotesSort MNPropCreatedTime Descending]) (Just 10)) @?= expected

testRawNode :: Assertion
testRawNode = do
  let raw = Aeson.object ["property" .= ("title" :: Text)]
  lookupPath ["filters"] (Aeson.toJSON (mnAnd [MNRawNode raw])) @?= Just (Aeson.toJSON [raw])

testDecodeQueryResponse :: Assertion
testDecodeQueryResponse =
  case Aeson.eitherDecode ("{\"results\":[" <> blockFixture <> "],\"has_more\":false}") of
    Right QueryMeetingNotesResponse {results, hasMore} -> do
      Vector.length results @?= 1
      hasMore @?= False
    Left err -> assertFailure err