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
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222 | (ns ird.transport-test
(:require [cljs.test :refer [deftest is testing]]
[cljs.core.async :refer [<!]]
[ird.client :as ird]
[ird.errors :as err]
[ird.test-util :as tu :refer [deftest-async]]
[ird.transport :as transport]))
(def test-client (ird/make-client {:api-key "ird_test-key"}))
(def anon-client (ird/make-client {}))
(deftest build-request-test
(testing "GET carries bearer auth and accept header, no body"
(let [req (transport/build-request test-client :get ["vps" "tiers"] nil)]
(is (= :get (:method req)))
(is (= "https://api.ird.ro/vps/tiers" (:url req)))
(is (= "Bearer ird_test-key" (get-in req [:headers "Authorization"])))
(is (nil? (:body req)))
(is (nil? (get-in req [:headers "Content-Type"])))))
(testing "missing api-key does not throw and sends no Authorization header"
(let [req (transport/build-request anon-client :get "/domains/check"
{:query {:domain "example.ro"}})]
(is (nil? (get-in req [:headers "Authorization"])))
(is (= "https://api.ird.ro/domains/check?domain=example.ro" (:url req)))))
(testing "query params are encoded and nils skipped"
(let [req (transport/build-request test-client :get "/domains/search"
{:query {:q "web hosting" :count 5 :skip nil}})]
(is (= "https://api.ird.ro/domains/search?q=web%20hosting&count=5" (:url req)))))
(testing "keyword query values are encoded by name, without the colon"
(let [req (transport/build-request test-client :get "/ipfs/pins" {:query {:status :pinned}})]
(is (= "https://api.ird.ro/ipfs/pins?status=pinned" (:url req)))))
(testing "path segments are encoded one by one, spaces as %20 and slashes escaped"
(let [req (transport/build-request test-client :get ["dns" "domains" "a b/c.ro"] nil)]
(is (= "https://api.ird.ro/dns/domains/a%20b%2Fc.ro" (:url req))))
(is (= "%2A%21" (transport/encode "*!"))))
(testing ":headers and :timeout-ms ride along without clobbering computed headers"
(let [client (ird/make-client {:api-key "k" :timeout-ms 30000
:headers {"User-Agent" "my-app"}})
req (transport/build-request client :post "/jobs" {:body {:a 1}})]
(is (= 30000 (:timeout-ms req)))
(is (= "my-app" (get-in req [:headers "User-Agent"])))
(is (= "Bearer k" (get-in req [:headers "Authorization"])))
(is (= "application/json" (get-in req [:headers "Content-Type"])))))
(testing "POST serialises a JSON body"
(let [req (transport/build-request test-client :post "/jobs"
{:body {:type "embedding" :input "hi"}})]
(is (= {:type "embedding" :input "hi"} (tu/json-body req)))))
(testing "DELETE can carry a JSON body"
(let [req (transport/build-request test-client :delete ["dns" "domains" "x.ro" "records"]
{:body {:name "www" :rtype "A"}})]
(is (= :delete (:method req)))
(is (= "application/json" (get-in req [:headers "Content-Type"])))
(is (= "www" (:name (tu/json-body req))))))
(testing "api-url override, trailing slash and path normalisation"
(let [req (transport/build-request (ird/make-client {:api-key "k" :api-url "http://localhost:8080/"})
:get "credits/balance" nil)]
(is (= "http://localhost:8080/credits/balance" (:url req))))))
(deftest make-client-test
(testing "defaults"
(let [c (ird/make-client {})]
(is (= "https://api.ird.ro" (:api-url c)))
(is (fn? (:http-fn c)))
(is (fn? (:sleep-fn c)))
(is (false? (:verbose c)))))
(testing "gate options are refused synchronously"
(is (thrown-with-msg? ExceptionInfo #"make-gated-client"
(ird/make-client {:api-key "k" :denied-domains ["x.ro"]})))))
(deftest decode-response-test
(testing "empty 2xx -> {} with status metadata"
(let [r (transport/decode-response {:status 204 :body ""})]
(is (= {} r))
(is (= 204 (:ird/status (meta r))))))
(testing "hyphenated keys survive"
(is (= {:job-id "j1"} (transport/decode-response {:status 200 :body "{\"job-id\":\"j1\"}"}))))
(testing "a JSON array is returned as a vector"
(is (= [1 2] (transport/decode-response {:status 200 :body "[1,2]"})))))
(deftest-async request-response-handling
(let [client (fn [http] (ird/make-client {:api-key "ird_k" :http-fn http}))]
;; parses JSON keeping snake_case keys, with status metadata
(let [resp (<! (transport/request (client (tu/responding 200 {:balance {:credits 42} :next_cursor nil}))
:get "/credits/balance"))]
(is (= 42 (get-in resp [:balance :credits])))
(is (= 200 (:ird/status (meta resp)))))
;; 202 accepted is a success and distinguishable via metadata
(let [resp (<! (transport/request (client (tu/responding 202 {:job {:id "j1" :status "queued"}}))
:post "/jobs" {:body {:type "embedding" :input "x"}}))]
(is (= "queued" (get-in resp [:job :status])))
(is (= 202 (:ird/status (meta resp)))))
;; empty 2xx body -> empty map
(is (= {} (<! (transport/request (client (tu/responding 204 "")) :delete ["tunnels" "t1"]))))
;; non-2xx with ird error envelope -> api-error value
(let [e (<! (transport/request (client (tu/responding 402 {:error {:code "insufficient_credits"
:message "Not enough credits"}}))
:post "/domains/registrations" {:body {}}))]
(is (err/api-error? e))
(is (= "Not enough credits" (ex-message e)))
(is (= "insufficient_credits" (:code (ex-data e))))
(is (= 402 (:status (ex-data e)))))
;; the node's bare envelope
(let [e (<! (transport/request (client (tu/responding 413 {:error "message_too_large"})) :post "/x"))]
(is (err/api-error? e))
(is (= "message_too_large" (:code (ex-data e))))
(is (= "message_too_large" (ex-message e))))
;; non-2xx with non-JSON body -> api-error with the raw body as message
(let [e (<! (transport/request (client (tu/responding 503 "upstream unavailable")) :get "/vps/tiers"))]
(is (err/api-error? e))
(is (= 503 (:status (ex-data e))))
(is (= "upstream unavailable" (ex-message e))))
;; 2xx with non-JSON body -> transport-error
(let [e (<! (transport/request (client (tu/responding 200 "<html>nope</html>")) :get "/vps/tiers"))]
(is (err/transport-error? e))
(is (re-find #"Failed to parse JSON" (ex-message e))))
;; connection failure (rejected fetch) -> transport-error carrying the cause
(let [e (<! (transport/request (client (tu/failing (js/Error. "ECONNREFUSED"))) :get "/vps/tiers"))]
(is (err/transport-error? e))
(is (re-find #"ECONNREFUSED" (ex-message e)))
(is (= "https://api.ird.ro/vps/tiers" (:url (:detail (ex-data e))))))
;; a channel is a promise-chan: the value can be taken again
(let [ch (transport/request (client (tu/responding 200 {:ok true})) :get "/x")]
(is (= {:ok true} (<! ch)))
(is (= {:ok true} (<! ch))))))
(deftest-async paginate-test
;; cursor pagination
(let [captured (atom [])
client (ird/make-client
{:api-key "ird_k"
:http-fn (tu/scripted captured
[[200 {:registrations [{:id "r1"} {:id "r2"}] :next_cursor "c2"}]
[200 {:registrations [{:id "r3"}] :next_cursor nil}]])})
items (<! (transport/paginate client :get ["domains" "registrations"] :registrations {:limit 2}))]
(is (= ["r1" "r2" "r3"] (mapv :id items)))
(is (= ["/domains/registrations?limit=2" "/domains/registrations?limit=2&cursor=c2"]
(mapv tu/path-of @captured))))
;; an error on page two short-circuits
(let [client (ird/make-client
{:api-key "ird_k"
:http-fn (tu/scripted (atom [])
[[200 {:entries [{:id 1}] :next_cursor "c2"}]
[500 {:error {:code "boom" :message "boom"}}]])})
e (<! (transport/paginate client :get "/credits/ledger" :entries nil))]
(is (err/api-error? e)))
;; offset pagination walks pages of :limit until a short page ends it
(let [captured (atom [])
client (ird/make-client
{:api-key "ird_k"
:http-fn (tu/scripted captured
[[200 {:pins [{:id "p1"} {:id "p2"}] :total 5}]
[200 {:pins [{:id "p3"} {:id "p4"}] :total 5}]
[200 {:pins [{:id "p5"}] :total 5}]])})]
(is (= ["p1" "p2" "p3" "p4" "p5"]
(mapv :id (<! (transport/paginate-offset client :get ["ipfs" "domains" "d1" "pins"] :pins {:limit 2})))))
(is (= ["/ipfs/domains/d1/pins?limit=2&offset=0"
"/ipfs/domains/d1/pins?limit=2&offset=2"
"/ipfs/domains/d1/pins?limit=2&offset=4"]
(mapv tu/path-of @captured))))
;; an exactly-full last page needs one more (empty) request
(let [captured (atom [])
client (ird/make-client {:api-key "ird_k"
:http-fn (tu/scripted captured [[200 {:pins [{:id "p1"} {:id "p2"}] :total 2}]
[200 {:pins [] :total 2}]])})]
(is (= 2 (count (<! (transport/paginate-offset client :get "/ipfs/pins" :pins {:limit 2})))))
(is (= 2 (count @captured))))
;; filters ride along and the default page size is 100
(let [captured (atom [])
client (ird/make-client {:api-key "ird_k"
:http-fn (tu/scripted captured [[200 {:pins [{:id "p1"}]}]])})]
(is (= 1 (count (<! (transport/paginate-offset client :get "/ipfs/pins" :pins {:status "pinned"})))))
;; a map literal inside a go block is a hash map, so param order is not
;; preserved there; the API does not care, and neither does this test
(is (= #{"status=pinned" "limit=100" "offset=0"}
(set (.split (second (.split (tu/path-of (first @captured)) "?")) "&"))))))
(deftest-async poll-until-test
;; polls until done, backing off through the sleep-fn
(let [slept (atom [])
sleep (fn [ms] (swap! slept conj ms) (tu/no-sleep ms))
n (atom 0)
step (fn [] (swap! n inc) (cljs.core.async/go {:n @n}))
result (<! (transport/poll-until step #(= 4 (:n %))
{:interval-ms 100 :max-interval-ms 300 :sleep-fn sleep}))]
(is (= {:n 4} result))
(is (= [100 200 300] @slept)))
;; a passed deadline delivers :ird/timeout with the last result
(let [step (fn [] (cljs.core.async/go {:status "processing"}))
e (<! (transport/poll-until step #(= "done" (:status %))
{:timeout-ms -1 :sleep-fn tu/no-sleep :message "gave up"}))]
(is (err/timeout? e))
(is (= "gave up" (ex-message e)))
(is (= {:status "processing"} (:last (ex-data e)))))
;; an error from a step is delivered as is
(let [step (fn [] (cljs.core.async/go (err/api-error "nope" "x" 500 nil)))
e (<! (transport/poll-until step (constantly false) {:sleep-fn tu/no-sleep}))]
(is (err/api-error? e))))
(deftest-async upload-bytes-test
;; posts octet-stream with grant token to the external URL
(let [captured (atom [])]
(<! (transport/upload-bytes "https://node1.ird.ro/up/abc" "grant-tok" (js/Buffer.from "hello")
{:http-fn (tu/capturing captured)}))
(let [req (first @captured)]
(is (= :post (:method req)))
(is (= "https://node1.ird.ro/up/abc" (:url req)))
(is (= "Bearer grant-tok" (get-in req [:headers "Authorization"])))
(is (= "application/octet-stream" (get-in req [:headers "Content-Type"])))
(is (instance? js/Uint8Array (:body req)))))
;; car format appends query param; nil token omits auth header
(let [captured (atom [])]
(<! (transport/upload-bytes "https://node1.ird.ro/up/abc" nil (js/Uint8Array. 1)
{:format "car" :http-fn (tu/capturing captured)}))
(let [req (first @captured)]
(is (= "https://node1.ird.ro/up/abc?format=car" (:url req)))
(is (nil? (get-in req [:headers "Authorization"])))))
;; unsupported source -> transport-error, no request
(let [captured (atom [])
e (<! (transport/upload-bytes "https://node1.ird.ro/up/abc" "t" 42 {:http-fn (tu/capturing captured)}))]
(is (err/transport-error? e))
(is (re-find #"Unsupported upload source" (ex-message e)))
(is (empty? @captured))))
|