packages feed

hstratus-drive-0.1.0.0: test/HStratus/DriveSpec.hs

{-# LANGUAGE OverloadedStrings #-}

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

Integration tests for the iCloud Drive API client.
-}
module HStratus.DriveSpec (spec) where

import Control.Exception (displayException)
import Data.Aeson (object)
import qualified Data.ByteString as BS
import qualified Data.ByteString.Char8 as BS8
import qualified Data.ByteString.Lazy as LBS
import qualified Data.ByteString.Lazy.Char8 as LBS8
import Data.IORef (IORef, newIORef, readIORef, writeIORef)
import qualified Data.Map.Strict as Map
import qualified Data.Text as Text
import Network.HStratus.Drive
import Network.HStratus.Http (mkApiWith)
import Network.HStratus.Http.Endpoints (Endpoints (..))
import Network.HStratus.Session (AccountData (..), Credentials (..), Session (..), Webservice (..))
import Network.HTTP.Client (Request (..), defaultManagerSettings, defaultRequest, newManager)
import Network.HTTP.Types (HeaderName, hContentType, methodPost, status200, status404)
import Network.Wai (Application, rawPathInfo, responseLBS)
import Network.Wai.Handler.Warp (testWithApplication)
import System.IO.Temp (withSystemTempDirectory)
import Test.Hspec


spec :: Spec
spec = describe "Network.HStratus.Drive" $ do
  describe "matchFolderName" $ do
    it "is True for a DriveFolder with the matching name" $
      matchFolderName "Documents" (DriveFolder testFolderData) `shouldBe` True
    it "is False for a DriveFolder with a different name" $
      matchFolderName "Other" (DriveFolder testFolderData) `shouldBe` False
    it "is False for a DriveFile" $
      matchFolderName "Documents" (DriveFile testFileData) `shouldBe` False

  describe "selectFileNode" $ do
    it "returns Just DriveFile when a file's full name matches" $
      selectFileNode "Scan 2.pdf" testNodes `shouldBe` Just (DriveFile testFileData)
    it "returns Just DriveFolder when a folder name matches" $
      selectFileNode "Documents" testNodes `shouldBe` Just (DriveFolder testFolderData)
    it "returns Nothing when no node matches" $
      selectFileNode "missing.txt" testNodes `shouldBe` Nothing

  describe "DriveError displayException" $ do
    it "DriveHttpError" $
      displayException (DriveHttpError 404) `shouldBe` "iCloud Drive: HTTP error 404"
    it "DriveParseError" $
      displayException (DriveParseError "bad json") `shouldBe` "iCloud Drive: parse error: bad json"
    it "DriveInvalidRoot" $
      displayException DriveInvalidRoot `shouldBe` "iCloud Drive: invalid root node"

  describe "driveRoot" $ do
    it "returns root FolderData" $
      withNodeMock rootJson $ \da -> do
        fd <- driveRoot da
        fnId fd `shouldBe` DriveNodeId "FOLDER::com.apple.CloudDocs::root"

  describe "listFolder" $ do
    it "returns all children" $
      withNodeMock subfolderJson $ \da -> do
        nodes <- listFolder da (DriveNodeId "FOLDER::com.apple.CloudDocs::D5AA0425")
        length nodes `shouldBe` 2
    it "returns DriveFile nodes for file children" $
      withNodeMock subfolderJson $ \da -> do
        nodes <- listFolder da (DriveNodeId "FOLDER::com.apple.CloudDocs::D5AA0425")
        all isFile nodes `shouldBe` True

  describe "downloadFile" $ do
    it "returns LBS.empty when size is absent" $
      withNodeMock rootJson $ \da -> do
        let fd = testFileData{fdSize = Nothing}
        downloadFile da fd `shouldReturn` LBS.empty
    it "returns LBS.empty when size is zero" $
      withNodeMock rootJson $ \da -> do
        let fd = testFileData{fdSize = Just 0}
        downloadFile da fd `shouldReturn` LBS.empty
    it "downloads file contents" $
      withDownloadMock $ \da ->
        downloadFile da testFileData `shouldReturn` "test file content"


-- Mock servers

withNodeMock :: LBS.ByteString -> (DriveApi -> IO a) -> IO a
withNodeMock nodeJson action =
  withSystemTempDirectory "icloud-drive-mock" $ \tmpDir ->
    testWithApplication (pure (nodeApp nodeJson)) $ \serverPort -> do
      da <- mkEpAndApi serverPort tmpDir
      action da


withDownloadMock :: (DriveApi -> IO a) -> IO a
withDownloadMock action =
  withSystemTempDirectory "icloud-drive-download" $ \tmpDir -> do
    portRef <- newIORef 0
    testWithApplication (pure (downloadApp portRef)) $ \serverPort -> do
      writeIORef portRef serverPort
      da <- mkEpAndApi serverPort tmpDir
      action da


nodeApp :: LBS.ByteString -> Application
nodeApp nodeJson req respond
  | "/retrieveItemDetailsInFolders" `BS.isSuffixOf` rawPathInfo req =
      respond $ responseLBS status200 jsonHeaders nodeJson
  | otherwise =
      respond $ responseLBS status404 [] "not found"


downloadApp :: IORef Int -> Application
downloadApp portRef req respond = do
  serverPort <- readIORef portRef
  let p = rawPathInfo req
  if "/download/by_id" `BS.isSuffixOf` p
    then
      let url = "http://127.0.0.1:" ++ show serverPort ++ "/content/test"
          body = LBS8.pack $ "{\"data_token\":{\"url\":\"" ++ url ++ "\",\"token\":\"tok\"}}"
       in respond $ responseLBS status200 jsonHeaders body
    else
      if p == "/content/test"
        then respond $ responseLBS status200 [] "test file content"
        else respond $ responseLBS status404 [] "not found"


mkEpAndApi :: Int -> FilePath -> IO DriveApi
mkEpAndApi serverPort tmpDir = do
  let baseUrl = Text.pack $ "http://127.0.0.1:" ++ show serverPort
  mgr <- newManager defaultManagerSettings
  api <- mkApiWith (testSession tmpDir) (testAuthEndpoints serverPort) mgr
  mkDriveApi (testAccountData baseUrl) (testSession tmpDir) api


-- Fixtures

testAccountData :: Text.Text -> AccountData
testAccountData baseUrl =
  AccountData
    { adHsaVersion = 2
    , adHsaChallengeRequired = False
    , adHsaTrustedBrowser = Just True
    , adWebservices = Map.fromList [("drivews", Webservice baseUrl Nothing), ("docws", Webservice baseUrl Nothing)]
    , adRaw = object []
    }


testSession :: FilePath -> Session
testSession topDir =
  Session
    { sessionCreds = Credentials{credAccountName = "test@example.com", credPassword = "test-pass"}
    , sessionTopDir = topDir
    , sessionClientId = "auth-test-client-id"
    }


testAuthEndpoints :: Int -> Endpoints
testAuthEndpoints serverPort =
  Endpoints
    { epHome = "http://127.0.0.1:" <> BS8.pack (show serverPort)
    , epAuth = dummyReq "/appleauth/auth"
    , epSetup = dummyReq "/setup/ws/1"
    , epWidgetKey = "test-widget-key"
    }
 where
  dummyReq reqPath =
    defaultRequest
      { host = "127.0.0.1"
      , port = serverPort
      , secure = False
      , method = methodPost
      , path = reqPath
      }


testFileData :: FileData
testFileData =
  FileData
    { fdId = DriveNodeId "FILE::com.apple.CloudDocs::33A41112"
    , fdDocId = "33A41112"
    , fdEtag = "2k::2j"
    , fdName = "Scan 2"
    , fdExtension = Just "pdf"
    , fdZone = "com.apple.CloudDocs"
    , fdSize = Just 19876991
    , fdDateCreated = Nothing
    , fdDateModified = Nothing
    }


testFolderData :: FolderData
testFolderData =
  FolderData
    { fnId = DriveNodeId "FOLDER::com.apple.CloudDocs::DOCS"
    , fnEtag = "1a"
    , fnName = "Documents"
    , fnZone = "com.apple.CloudDocs"
    , fnDateCreated = Nothing
    }


testNodes :: [DriveNode]
testNodes = [DriveFile testFileData, DriveFolder testFolderData]


isFile :: DriveNode -> Bool
isFile (DriveFile _) = True
isFile _ = False


jsonHeaders :: [(HeaderName, BS8.ByteString)]
jsonHeaders = [(hContentType, "application/json")]


rootJson :: LBS.ByteString
rootJson =
  "[{\"drivewsid\":\"FOLDER::com.apple.CloudDocs::root\"\
  \,\"zone\":\"com.apple.CloudDocs\"\
  \,\"name\":\"\"\
  \,\"etag\":\"31\"\
  \,\"type\":\"FOLDER\"\
  \}]"


subfolderJson :: LBS.ByteString
subfolderJson =
  "[{\"drivewsid\":\"FOLDER::com.apple.CloudDocs::D5AA0425-E84F-4501-AF5D-60F1D92648CF\"\
  \,\"zone\":\"com.apple.CloudDocs\"\
  \,\"name\":\"Test\"\
  \,\"etag\":\"2z\"\
  \,\"type\":\"FOLDER\"\
  \,\"items\":[\
  \{\"drivewsid\":\"FILE::com.apple.CloudDocs::33A41112-4131-4938-9691-7F356CE3C51D\"\
  \,\"docwsid\":\"33A41112-4131-4938-9691-7F356CE3C51D\"\
  \,\"zone\":\"com.apple.CloudDocs\"\
  \,\"name\":\"Scan 2\"\
  \,\"dateModified\":\"2020-04-27T21:37:36Z\"\
  \,\"size\":19876991\
  \,\"etag\":\"2k::2j\"\
  \,\"extension\":\"pdf\"\
  \,\"type\":\"FILE\"\
  \}\
  \,{\"drivewsid\":\"FILE::com.apple.CloudDocs::516C896C-6AA5-4A30-B30E-5502C2333DAE\"\
  \,\"docwsid\":\"516C896C-6AA5-4A30-B30E-5502C2333DAE\"\
  \,\"zone\":\"com.apple.CloudDocs\"\
  \,\"name\":\"Scanned document 1\"\
  \,\"dateModified\":\"2020-05-03T00:15:17Z\"\
  \,\"size\":21644358\
  \,\"etag\":\"32::2x\"\
  \,\"extension\":\"pdf\"\
  \,\"type\":\"FILE\"\
  \}\
  \]}]"