cljs / test / ird / flows_test.cljs
950 words
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
(ns ird.flows-test
  "Scripted-sequence tests for the multi-request convenience helpers."
  (:require [cljs.test :refer [deftest is testing]]
            [cljs.core.async :refer [<! go]]
            [ird.auth :as auth]
            [ird.client :as ird]
            [ird.errors :as err]
            [ird.ipfs :as ipfs]
            [ird.jobs :as jobs]
            [ird.test-util :as tu :refer [deftest-async]]))

(defn- client [captured responses]
  (ird/make-client {:api-key "ird_k"
                    :http-fn (tu/scripted captured responses)
                    :sleep-fn tu/no-sleep}))

(deftest job-builders-test
  (testing "every job type has a builder producing its typed payload"
    (is (= {:type "domain-availability-bulk" :domains ["a.ro" "b.ro"]}
           (jobs/domain-availability-bulk ["a.ro" "b.ro"])))
    (is (= {:type "embedding" :input "text"} (jobs/embedding "text")))
    (is (= {:type "embedding" :input "text" :model "BAAI/bge-m3"}
           (jobs/embedding "text" {:model "BAAI/bge-m3"})))
    (is (= {:type "llm-chat" :messages [{:role "user" :content "hi"}] :max_tokens 64}
           (jobs/llm-chat [{:role "user" :content "hi"}] {:max_tokens 64})))
    (is (= {:type "llm-raw-completion" :prompt "Once" :temperature 0.7}
           (jobs/llm-raw-completion "Once" {:temperature 0.7})))
    (is (= {:type "nlp-analyze" :input "text" :language "ro"}
           (jobs/nlp-analyze "text" {:language "ro"})))
    (is (= {:type "nlp-detect-language" :input "salut"} (jobs/nlp-detect-language "salut"))))
  (testing "opts cannot override the type or the primary input"
    (is (= "embedding" (:type (jobs/embedding "x" {:type "other" :input "y"}))))
    (is (= "x" (:input (jobs/embedding "x" {:type "other" :input "y"}))))))

(deftest-async multimodal-helpers-test
  (is (= {:type "text" :text "describe this"} (jobs/text-part "describe this")))
  ;; bytes with an explicit MIME type
  (let [part (<! (jobs/image-part (js/Buffer.from "fakepng") "image/png"))]
    (is (= "image_url" (:type part)))
    (is (= "data:image/png;base64,ZmFrZXBuZw==" (get-in part [:image_url :url]))))
  ;; a message with parts
  (let [msg (jobs/user-message "what is here?" (<! (jobs/image-part (js/Uint8Array. 1) "image/webp")))]
    (is (= "user" (:role msg)))
    (is (= 2 (count (:content msg))))
    (is (= "text" (get-in msg [:content 0 :type]))))
  ;; MIME sniffing from the file extension, via node:fs
  (let [part (<! (jobs/image-part "spec/openapi.json" "application/json"))]
    (is (re-find #"^data:application/json;base64," (get-in part [:image_url :url]))))
  ;; no extension to sniff -> config error
  (let [e (<! (jobs/image-part (js/Uint8Array. 1)))]
    (is (err/config-error? e))
    (is (re-find #"Cannot guess image MIME type" (ex-message e))))
  ;; unreadable path -> the fs error is delivered
  (is (tu/error? (<! (jobs/image-part "no/such/file.png")))))

(deftest-async jobs-run-test
  ;; enqueue -> poll through processing -> fetch result
  (let [captured (atom [])
        c (client captured [[202 {:job {:id "j1" :status "queued"}}]
                            [200 {:job {:id "j1" :status "processing"}}]
                            [200 {:job {:id "j1" :status "succeeded"}}]
                            [200 {:result {:results [{:domain "a.ro" :status "available"}]}}]])
        result (<! (jobs/run c (jobs/domain-availability-bulk ["a.ro"])))]
    (is (= "available" (get-in result [:result :results 0 :status])))
    (is (= ["POST /jobs" "GET /jobs/j1" "GET /jobs/j1" "GET /jobs/j1/result"]
           (mapv tu/op-of @captured))))
  ;; failed job -> :ird/job-failed with the job map
  (let [c (client (atom []) [[202 {:job {:id "j2" :status "queued"}}]
                             [200 {:job {:id "j2" :status "failed" :error "boom"}}]])
        e (<! (jobs/run c (jobs/embedding "x")))]
    (is (err/job-failed? e))
    (is (= "j2" (get-in (ex-data e) [:job :id]))))
  ;; deadline exceeded -> :ird/timeout
  (let [c (client (atom []) [[202 {:job {:id "j3" :status "queued"}}]
                             [200 {:job {:id "j3" :status "processing"}}]])
        e (<! (jobs/run c (jobs/embedding "x") {:timeout-ms -1}))]
    (is (err/timeout? e)))
  ;; an API error while enqueueing is delivered as is
  (let [c (client (atom []) [[402 {:error {:code "insufficient_credits" :message "no"}}]])
        e (<! (jobs/run c (jobs/embedding "x")))]
    (is (err/api-error? e))
    (is (= 402 (:status (ex-data e))))))

(deftest-async ipfs-add-file-test
  ;; grant -> raw upload to grant URL -> poll until completed
  (let [captured (atom [])
        c (client captured [[200 {:upload {:id "up1" :pin_id "p1" :url "https://node1.ird.ro/up/abc"
                                           :token "grant-tok" :status "issued"}}]
                            [200 {:ok true}]
                            [200 {:upload {:id "up1" :status "issued"}}]
                            [200 {:upload {:id "up1" :pin_id "p1" :status "completed" :cid "bafy123"}}]])
        upload (<! (ipfs/add-file c (js/Buffer.from "hello ipfs") {:name "hello.txt"}))]
    (is (= "bafy123" (:cid upload)))
    (is (= "p1" (:pin_id upload)))
    (let [[start-req upload-req poll1 poll2] @captured]
      (is (= "POST /ipfs/uploads" (tu/op-of start-req)))
      (is (= {:name "hello.txt"} (tu/json-body start-req)))
      (is (= "https://node1.ird.ro/up/abc" (:url upload-req)))
      (is (= "Bearer grant-tok" (get-in upload-req [:headers "Authorization"])))
      (is (= "application/octet-stream" (get-in upload-req [:headers "Content-Type"])))
      (is (= "hello ipfs" (.toString (js/Buffer.from (:body upload-req)))))
      (is (= "GET /ipfs/uploads/up1" (tu/op-of poll1)))
      (is (= "GET /ipfs/uploads/up1" (tu/op-of poll2)))))
  ;; a file path is read from disk; car format reaches the grant URL
  (let [captured (atom [])
        c (client captured [[200 {:upload {:id "up3" :url "https://n/u" :token "t" :status "issued"}}]
                            [200 {:ok true}]
                            [200 {:upload {:id "up3" :status "completed" :cid "c"}}]])
        upload (<! (ipfs/add-file c "package.json" {:format "car"}))]
    (is (= "c" (:cid upload)))
    (is (= {:format "car"} (tu/json-body (first @captured))))
    (is (= "https://n/u?format=car" (:url (second @captured))))
    (is (re-find #"ird-cljs" (.toString (js/Buffer.from (:body (second @captured)))))))
  ;; failed upload -> :ird/upload-failed
  (let [c (client (atom []) [[200 {:upload {:id "up2" :url "https://n/u" :token "t" :status "issued"}}]
                             [200 {:ok true}]
                             [200 {:upload {:id "up2" :status "failed"}}]])
        e (<! (ipfs/add-file c (js/Uint8Array. 1)))]
    (is (err/upload-failed? e))
    (is (= "up2" (get-in (ex-data e) [:upload :id]))))
  ;; an unreadable path fails before any request
  (let [captured (atom [])
        e (<! (ipfs/add-file (client captured []) "no/such/file"))]
    (is (tu/error? e))
    (is (empty? @captured))))

(deftest-async device-login-test
  ;; start -> prompt -> poll until approved -> complete
  (let [captured (atom [])
        prompted (atom nil)
        c (ird/make-client
           {:sleep-fn tu/no-sleep
            :http-fn (tu/scripted captured
                                  [[200 {:device_code "dc" :user_code "AB-12"
                                         :verification_uri "https://ird.ro/device"
                                         :verification_uri_complete "https://ird.ro/device?code=AB-12"
                                         :interval 1 :expires_in 600}]
                                   [400 {:error {:code "authorization-pending" :message "authorization pending"}}]
                                   [429 {:error {:code "slow-down" :message "slow down"}}]
                                   [200 {:approved true}]
                                   [200 {:access_token "jwt-token" :refresh_token "r" :user {:email "x@y.ro"}}]])})
        tokens (<! (auth/device-login c {:on-prompt (fn [start] (reset! prompted (:user_code start)))
                                         :confirm-fn (fn [_] (go "CONF-99"))}))]
    (is (= "jwt-token" (:access_token tokens)))
    (is (= "AB-12" @prompted))
    (is (= ["POST /auth/device/start" "POST /auth/device/poll" "POST /auth/device/poll"
            "POST /auth/device/poll" "POST /auth/device/complete"]
           (mapv tu/op-of @captured)))
    (is (= {:device_code "dc" :confirmation_code "CONF-99"} (tu/json-body (last @captured))))
    (is (nil? (get-in (first @captured) [:headers "Authorization"])) "device flow is anonymous"))
  ;; a terminal poll error (403 denied) is delivered, not swallowed
  (let [c (ird/make-client
           {:sleep-fn tu/no-sleep
            :http-fn (tu/scripted (atom [])
                                  [[200 {:device_code "dc" :user_code "AB" :interval 1 :expires_in 600}]
                                   [403 {:error {:code "access-denied" :message "denied"}}]])})
        e (<! (auth/device-login c {:on-prompt (fn [_]) :confirm-fn (fn [_] (go "x"))}))]
    (is (err/api-error? e))
    (is (= 403 (:status (ex-data e))))))

static mirror of HEAD · about · clone: git clone https://ird.cod-sursa.ro/cljs.git