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 | (ns ird.transport-test
(:require
[cheshire.core :as json]
[clojure.test :refer [deftest is testing]]
[ird.client :as ird]
[ird.errors :as err]
[ird.transport :as transport]))
(def test-client
(ird/make-client {:api-key "ird_test-key"}))
(def anon-client
(ird/make-client {}))
(defn- responding
"An *http-fn* returning STATUS and BODY (a map serialised to JSON, or a raw
string) for every request."
[status body]
(fn [_]
{:status status
:body (if (string? body) body (json/generate-string body))}))
(defn- capturing
"An *http-fn* recording each request into CAPTURED (an atom) and answering
{} with 200."
[captured]
(fn [req]
(swap! captured conj req)
{:status 200 :body "{}"}))
(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" (:uri req)))
(is (= "Bearer ird_test-key" (get-in req [:headers "Authorization"])))
(is (nil? (:body req)))))
(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" (:uri 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" (:uri 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" (:uri req)))))
(testing ":http-opts merges into the request without clobbering headers"
(let [client (ird/make-client {:api-key "k"
:http-opts {:timeout 30000
:headers {"User-Agent" "my-app"}}})
req (transport/build-request client :post "/jobs" {:body {:a 1}})]
(is (= 30000 (:timeout 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 (= "application/json" (get-in req [:headers "Content-Type"])))
(is (= {:type "embedding" :input "hi"}
(json/parse-string (:body req) true)))))
(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 (json/parse-string (:body req) true))))))
(testing "api-url override 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" (:uri req))))))
(deftest request-response-handling
(testing "parses JSON body keeping snake_case keys, with status metadata"
(binding [transport/*http-fn* (responding 200 {:balance {:credits 42} :next_cursor nil})]
(let [resp (transport/request test-client :get "/credits/balance")]
(is (= 42 (get-in resp [:balance :credits])))
(is (= 200 (:ird/status (meta resp)))))))
(testing "202 accepted is a success and distinguishable via metadata"
(binding [transport/*http-fn* (responding 202 {:job {:id "j1" :status "queued"}})]
(let [resp (transport/request test-client :post "/jobs" {:body {:type "embedding" :input "x"}})]
(is (= "queued" (get-in resp [:job :status])))
(is (= 202 (:ird/status (meta resp)))))))
(testing "empty 2xx body -> empty map"
(binding [transport/*http-fn* (responding 204 "")]
(is (= {} (transport/request test-client :delete "/tunnels/t1")))))
(testing "non-2xx with ird error envelope -> api-error"
(binding [transport/*http-fn* (responding 402 {:error {:code "insufficient_credits"
:message "Not enough credits"}})]
(let [e (try (transport/request test-client :post "/domains/registrations" {:body {}})
(catch clojure.lang.ExceptionInfo ex ex))]
(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)))))))
(testing "non-2xx with non-JSON body -> api-error with raw body message"
(binding [transport/*http-fn* (responding 503 "upstream unavailable")]
(let [e (try (transport/request test-client :get "/vps/tiers")
(catch clojure.lang.ExceptionInfo ex ex))]
(is (err/api-error? e))
(is (= 503 (:status (ex-data e)))))))
(testing "2xx with non-JSON body -> transport-error"
(binding [transport/*http-fn* (responding 200 "<html>nope</html>")]
(is (thrown-with-msg? clojure.lang.ExceptionInfo
#"Failed to parse JSON"
(transport/request test-client :get "/vps/tiers")))))
(testing "connection failure -> transport-error"
(binding [transport/*http-fn* (fn [_] (throw (java.net.ConnectException. "refused")))]
(let [e (try (transport/request test-client :get "/vps/tiers")
(catch clojure.lang.ExceptionInfo ex ex))]
(is (err/transport-error? e))))))
(deftest paginate-follows-next-cursor
(testing "collects all items across cursor pages"
(let [pages (atom [{:registrations [{:id "r1"} {:id "r2"}] :next_cursor "c2"}
{:registrations [{:id "r3"}] :next_cursor nil}])
seen (atom [])]
(binding [transport/*http-fn*
(fn [req]
(swap! seen conj (:uri req))
{:status 200 :body (json/generate-string
(let [[page & more] @pages]
(reset! pages (vec more))
page))})]
(let [items (transport/paginate test-client :get "/domains/registrations"
:registrations {:limit 2})]
(is (= ["r1" "r2" "r3"] (mapv :id items)))
(is (= 2 (count @seen)))
(is (re-find #"cursor=c2" (second @seen))))))))
(deftest upload-bytes-test
(testing "posts octet-stream with grant token to the external URL"
(let [captured (atom [])]
(binding [transport/*http-fn* (capturing captured)]
(transport/upload-bytes "https://node1.ird.ro/up/abc" "grant-tok" (.getBytes "hello"))
(let [req (first @captured)]
(is (= :post (:method req)))
(is (= "https://node1.ird.ro/up/abc" (:uri req)))
(is (= "Bearer grant-tok" (get-in req [:headers "Authorization"])))
(is (= "application/octet-stream" (get-in req [:headers "Content-Type"])))
(is (bytes? (:body req)))))))
(testing "car format appends query param; nil token omits auth header"
(let [captured (atom [])]
(binding [transport/*http-fn* (capturing captured)]
(transport/upload-bytes "https://node1.ird.ro/up/abc" nil (.getBytes "x") {:format "car"})
(let [req (first @captured)]
(is (= "https://node1.ird.ro/up/abc?format=car" (:uri req)))
(is (nil? (get-in req [:headers "Authorization"])))))))
(testing "file path source becomes a File body"
(let [captured (atom [])]
(binding [transport/*http-fn* (capturing captured)]
(transport/upload-bytes "https://node1.ird.ro/up/abc" "t" "/tmp/some-file.bin")
(is (instance? java.io.File (:body (first @captured)))))))
(testing "unsupported source throws transport-error"
(is (thrown-with-msg? clojure.lang.ExceptionInfo
#"Unsupported upload source"
(transport/upload-bytes "https://node1.ird.ro/up/abc" "t" 42)))))
|