packages feed

hstratus-notes-0.1.0.0: test/HStratus/Notes/NoteDataSpec.hs

{-# LANGUAGE OverloadedStrings #-}

{- |
Module      : HStratus.Notes.NoteDataSpec
Copyright   : (c) 2026 Tim Emiola
Maintainer  : Tim Emiola <adetokunbo@emio.la>
SPDX-License-Identifier: BSD-3-Clause

Tests for the CloudKit-to-domain record converters in
'Network.HStratus.Internal.Notes.NoteData'.
-}
module HStratus.Notes.NoteDataSpec (spec) where

import Data.Aeson (FromJSON, eitherDecode)
import qualified Data.ByteString.Lazy as LBS
import Data.Time.Clock.POSIX (posixSecondsToUTCTime)
import Network.HStratus.Internal.Notes.CloudKit
  ( CKLookupResponse (..)
  , CKQueryResponse (..)
  , CKZoneChangesResponse (..)
  , CKZoneChangesZone (..)
  )
import Network.HStratus.Internal.Notes.NoteData
  ( noteRecordToFolder
  , noteRecordToNote
  , noteRecordToSummary
  , parseFoldersFromChanges
  , parseFoldersFromQuery
  , parseSummariesFromChanges
  , parseSummariesFromQuery
  )
import Network.HStratus.Notes.Note
import Test.Hspec


spec :: Spec
spec = describe "Network.HStratus.Internal.Notes.NoteData" $ do
  describe "noteRecordToSummary" $ do
    it "parses a note record into a NoteSummary" $ do
      r <- decodeOrFail lookupNoteJson :: IO CKLookupResponse
      case lrRecords r of
        [] -> expectationFailure "expected records"
        rec : _ ->
          noteRecordToSummary rec
            `shouldBe` Just
              NoteSummary
                { nsId = NoteId "Note/NOTE-FIXTURE"
                , nsTitle = Just "Synthetic note"
                , nsSnippet = Just "Synthetic snippet"
                , nsModified = Just (posixSecondsToUTCTime 1735776000)
                , nsFolderId = Just (FolderId "Folder/FOLDER-FIXTURE")
                , nsDeleted = False
                , nsLocked = False
                }
    it "parses a SearchIndexes record into a NoteSummary with nsLocked = False" $ do
      r <- decodeOrFail querySearchIndexJson :: IO CKQueryResponse
      case qrRecords r of
        [] -> expectationFailure "expected records"
        rec : _ ->
          noteRecordToSummary rec
            `shouldBe` Just
              NoteSummary
                { nsId = NoteId "Note/NOTE-FIXTURE"
                , nsTitle = Just "Synthetic note"
                , nsSnippet = Just "Synthetic snippet"
                , nsModified = Just (posixSecondsToUTCTime 1735776000)
                , nsFolderId = Just (FolderId "Folder/FOLDER-FIXTURE")
                , nsDeleted = False
                , nsLocked = False
                }
    it "returns Nothing for a tombstone" $ do
      r <- decodeOrFail zoneChangesJson :: IO CKZoneChangesResponse
      case concatMap zczRecords (zcrZones r) of
        [_, _, tombstone] -> noteRecordToSummary tombstone `shouldBe` Nothing
        recs -> expectationFailure $ "expected 3 records, got " <> show (length recs)
    it "returns Nothing for a folder record" $ do
      r <- decodeOrFail queryFoldersJson :: IO CKQueryResponse
      case qrRecords r of
        [] -> expectationFailure "expected records"
        rec : _ -> noteRecordToSummary rec `shouldBe` Nothing

  describe "noteRecordToFolder" $ do
    it "parses a folder record into a NoteFolder" $ do
      r <- decodeOrFail queryFoldersJson :: IO CKQueryResponse
      case qrRecords r of
        [] -> expectationFailure "expected records"
        rec : _ ->
          noteRecordToFolder rec
            `shouldBe` Just
              NoteFolder
                { nfId = FolderId "Folder/FOLDER-FIXTURE"
                , nfName = Just "Synthetic Folder"
                }
    it "returns Nothing for a note record" $ do
      r <- decodeOrFail lookupNoteJson :: IO CKLookupResponse
      case lrRecords r of
        [] -> expectationFailure "expected records"
        rec : _ -> noteRecordToFolder rec `shouldBe` Nothing

  describe "noteRecordToNote" $ do
    it "decodes the note body from TextDataEncrypted" $ do
      r <- decodeOrFail lookupNoteJson :: IO CKLookupResponse
      case lrRecords r of
        [] -> expectationFailure "expected records"
        rec : _ -> case noteRecordToNote rec of
          Nothing -> expectationFailure "expected Just Note"
          Just n -> noteBodyBytes n `shouldBe` "synthetic note body"
    it "returns Nothing when TextDataEncrypted is absent" $ do
      r <- decodeOrFail zoneChangesJson :: IO CKZoneChangesResponse
      case concatMap zczRecords (zcrZones r) of
        noteRec : _ -> noteRecordToNote noteRec `shouldBe` Nothing
        [] -> expectationFailure "expected records"

  describe "parseSummariesFromQuery" $ do
    it "returns one summary from a note query response" $ do
      r <- decodeOrFail queryNotesJson :: IO CKQueryResponse
      length (parseSummariesFromQuery r) `shouldBe` 1
    it "returns one summary from a SearchIndexes query response" $ do
      r <- decodeOrFail querySearchIndexJson :: IO CKQueryResponse
      length (parseSummariesFromQuery r) `shouldBe` 1
    it "returns empty from a folders-only response" $ do
      r <- decodeOrFail queryFoldersJson :: IO CKQueryResponse
      parseSummariesFromQuery r `shouldBe` []

  describe "parseFoldersFromQuery" $ do
    it "returns one folder from a folders query response" $ do
      r <- decodeOrFail queryFoldersJson :: IO CKQueryResponse
      length (parseFoldersFromQuery r) `shouldBe` 1
    it "returns empty from a notes-only response" $ do
      r <- decodeOrFail queryNotesJson :: IO CKQueryResponse
      parseFoldersFromQuery r `shouldBe` []

  describe "parseSummariesFromChanges" $ do
    it "extracts only Note records, skipping tombstones and folders" $ do
      r <- decodeOrFail zoneChangesJson :: IO CKZoneChangesResponse
      length (parseSummariesFromChanges r) `shouldBe` 1

  describe "parseFoldersFromChanges" $ do
    it "extracts only Folder records" $ do
      r <- decodeOrFail zoneChangesJson :: IO CKZoneChangesResponse
      length (parseFoldersFromChanges r) `shouldBe` 1


-- Helpers

decodeOrFail :: (FromJSON a) => LBS.ByteString -> IO a
decodeOrFail bs = either fail pure (eitherDecode bs)


-- Fixtures

queryFoldersJson :: LBS.ByteString
queryFoldersJson =
  "{\"records\":[{\"recordName\":\"Folder/FOLDER-FIXTURE\"\
  \,\"recordType\":\"Folder\"\
  \,\"recordChangeTag\":\"folder-change-tag-fixture\"\
  \,\"zoneID\":{\"zoneName\":\"Notes\",\"zoneType\":\"REGULAR_CUSTOM_ZONE\"}\
  \,\"fields\":{\"TitleEncrypted\":{\"type\":\"ENCRYPTED_BYTES\",\"value\":\"U3ludGhldGljIEZvbGRlcg==\"}\
  \,\"HasSubfolder\":{\"type\":\"INT64\",\"value\":1}}}]\
  \,\"continuationMarker\":null}"


querySearchIndexJson :: LBS.ByteString
querySearchIndexJson =
  "{\"records\":[{\"recordName\":\"Note/NOTE-FIXTURE\"\
  \,\"recordType\":\"SearchIndexes\"\
  \,\"fields\":{\
  \\"TitleEncrypted\":{\"type\":\"ENCRYPTED_BYTES\",\"value\":\"U3ludGhldGljIG5vdGU=\"}\
  \,\"SnippetEncrypted\":{\"type\":\"ENCRYPTED_BYTES\",\"value\":\"U3ludGhldGljIHNuaXBwZXQ=\"}\
  \,\"ModificationDate\":{\"type\":\"TIMESTAMP\",\"value\":1735776000000}\
  \,\"Deleted\":{\"type\":\"INT64\",\"value\":0}\
  \,\"Folder\":{\"type\":\"REFERENCE\",\"value\":{\"recordName\":\"Folder/FOLDER-FIXTURE\",\"action\":\"VALIDATE\"}}}}]\
  \,\"continuationMarker\":null}"


queryNotesJson :: LBS.ByteString
queryNotesJson =
  "{\"records\":[{\"recordName\":\"Note/NOTE-FIXTURE\"\
  \,\"recordType\":\"Note\"\
  \,\"fields\":{\
  \\"TitleEncrypted\":{\"type\":\"ENCRYPTED_BYTES\",\"value\":\"U3ludGhldGljIG5vdGU=\"}\
  \,\"SnippetEncrypted\":{\"type\":\"ENCRYPTED_BYTES\",\"value\":\"U3ludGhldGljIHNuaXBwZXQ=\"}\
  \,\"ModificationDate\":{\"type\":\"TIMESTAMP\",\"value\":1735776000000}\
  \,\"Deleted\":{\"type\":\"INT64\",\"value\":0}\
  \,\"Folder\":{\"type\":\"REFERENCE\",\"value\":{\"recordName\":\"Folder/FOLDER-FIXTURE\",\"action\":\"VALIDATE\"}}}}]\
  \,\"continuationMarker\":null}"


lookupNoteJson :: LBS.ByteString
lookupNoteJson =
  "{\"records\":[{\"recordName\":\"Note/NOTE-FIXTURE\"\
  \,\"recordType\":\"Note\"\
  \,\"recordChangeTag\":\"note-change-tag-fixture\"\
  \,\"created\":{\"timestamp\":1735689600000,\"userRecordName\":\"_synthetic_user\"}\
  \,\"modified\":{\"timestamp\":1735776000000,\"userRecordName\":\"_synthetic_user\"}\
  \,\"deleted\":false\
  \,\"zoneID\":{\"zoneName\":\"Notes\",\"zoneType\":\"REGULAR_CUSTOM_ZONE\"}\
  \,\"fields\":{\
  \\"TitleEncrypted\":{\"type\":\"ENCRYPTED_BYTES\",\"value\":\"U3ludGhldGljIG5vdGU=\"}\
  \,\"SnippetEncrypted\":{\"type\":\"ENCRYPTED_BYTES\",\"value\":\"U3ludGhldGljIHNuaXBwZXQ=\"}\
  \,\"TextDataEncrypted\":{\"type\":\"ENCRYPTED_BYTES\",\"value\":\"c3ludGhldGljIG5vdGUgYm9keQ==\"}\
  \,\"ModificationDate\":{\"type\":\"TIMESTAMP\",\"value\":1735776000000}\
  \,\"Deleted\":{\"type\":\"INT64\",\"value\":0}\
  \,\"Folder\":{\"type\":\"REFERENCE\",\"value\":{\"recordName\":\"Folder/FOLDER-FIXTURE\",\"action\":\"VALIDATE\"}}\
  \,\"Attachments\":{\"type\":\"REFERENCE_LIST\",\"value\":[{\"recordName\":\"Attachment/ATTACHMENT-FIXTURE\",\"action\":\"VALIDATE\"}]}\
  \}}]\
  \,\"syncToken\":\"notes-lookup-sync-token-fixture\"}"


zoneChangesJson :: LBS.ByteString
zoneChangesJson =
  "{\"zones\":[{\"zoneID\":{\"zoneName\":\"Notes\",\"zoneType\":\"REGULAR_CUSTOM_ZONE\"}\
  \,\"syncToken\":\"notes-zone-sync-token-fixture\"\
  \,\"moreComing\":false\
  \,\"records\":[\
  \{\"recordName\":\"Note/NOTE-FIXTURE\"\
  \,\"recordType\":\"Note\"\
  \,\"recordChangeTag\":\"note-change-tag-fixture\"\
  \,\"fields\":{\
  \\"TitleEncrypted\":{\"type\":\"ENCRYPTED_BYTES\",\"value\":\"U3ludGhldGljIG5vdGU=\"}\
  \,\"SnippetEncrypted\":{\"type\":\"ENCRYPTED_BYTES\",\"value\":\"U3ludGhldGljIHNuaXBwZXQ=\"}\
  \,\"ModificationDate\":{\"type\":\"TIMESTAMP\",\"value\":1735776000000}\
  \,\"Deleted\":{\"type\":\"INT64\",\"value\":0}\
  \,\"Folder\":{\"type\":\"REFERENCE\",\"value\":{\"recordName\":\"Folder/FOLDER-FIXTURE\",\"action\":\"VALIDATE\"}}}}\
  \,{\"recordName\":\"Folder/FOLDER-FIXTURE\"\
  \,\"recordType\":\"Folder\"\
  \,\"recordChangeTag\":\"folder-change-tag-fixture\"\
  \,\"fields\":{\
  \\"TitleEncrypted\":{\"type\":\"ENCRYPTED_BYTES\",\"value\":\"U3ludGhldGljIEZvbGRlcg==\"}\
  \,\"HasSubfolder\":{\"type\":\"INT64\",\"value\":1}}}\
  \,{\"recordName\":\"Note/NOTE-DELETED-FIXTURE\",\"deleted\":true}\
  \]}]}"