add todo api
CI / Test + coverage (push) Failing after 22s

This commit is contained in:
2026-09-19 07:46:05 -09:00
parent 6ff03a45f7
commit b1783e3f85
22 changed files with 567 additions and 80 deletions
+17 -16
View File
@@ -1,20 +1,24 @@
(ns tbd.auth.routes-test
(:require [clojure.edn :as edn]
[clojure.string :as str]
(:require [clojure.string :as str]
[clojure.test :refer [deftest is testing]]
[tbd.auth.middleware :as mw]
[tbd.core :as core]))
[tbd.core :as core]
[tbd.test-util :as test-util]))
(defn gitea-stub
"Stands in for Gitea: any code is exchanged for a token that resolves to `login`."
[login]
(fn [{:keys [url]}]
(cond
(str/ends-with? url "/login/oauth/access_token") {:status 200 :body "{\"access_token\":\"tok\"}"}
(str/ends-with? url "/api/v1/user") {:status 200 :body (format "{\"login\":\"%s\",\"id\":7}" login)})))
"Stands in for Gitea: any code is exchanged for a token that resolves to `login` (with Gitea id 7, or `id`)."
([login] (gitea-stub login 7))
([login id]
(fn [{:keys [url]}]
(cond
(str/ends-with? url "/login/oauth/access_token") {:status 200 :body "{\"access_token\":\"tok\"}"}
(str/ends-with? url "/api/v1/user") {:status 200 :body (format "{\"login\":\"%s\",\"id\":%d}" login id)}))))
(defn make-app [http & {:as overrides}]
(core/make-app {:auth (merge {:gitea-url "https://gitea.test"
(defn make-app
"The app against a Gitea stub. `:todo` (a todo config) is passed through; other overrides go into the auth config."
[http & {:keys [todo] :as overrides}]
(core/make-app {:todo todo
:auth (merge {:gitea-url "https://gitea.test"
:client-id "cid"
:client-secret "sec"
:redirect-uri "http://tbd.test/auth/callback"
@@ -22,16 +26,13 @@
:session-secret "test-secret"
:secure-cookies? false
:http http}
overrides)
(dissoc overrides :todo))
:nrepl {:port 0 :bind "127.0.0.1"}}))
(defn request [app method uri & {:keys [cookie query]}]
(let [resp (app (cond-> {:request-method method :uri uri :query-string query :headers {}}
cookie (assoc-in [:headers "cookie"] cookie)))]
(cond-> resp
(and (string? (:body resp))
(str/includes? (get-in resp [:headers "Content-Type"] "") "edn"))
(update :body edn/read-string))))
(test-util/decode-body resp)))
(defn set-cookie
"The `name=value` pair from a response's Set-Cookie header."
+5 -7
View File
@@ -1,14 +1,12 @@
(ns tbd.math.routes-test
(:require [clojure.edn :as edn]
[clojure.test :refer [deftest is testing]]
(:require [clojure.test :refer [deftest is testing]]
[muuntaja.core :as m]
[tbd.core :as core]))
[tbd.core :as core]
[tbd.test-util :as test-util]))
;; Coercion/validation is configured on the router in tbd.core, so go through the real app.
(defn add [query]
(let [resp (core/app {:request-method :get :uri "/api/add" :query-string query})]
(-> resp
(update :body edn/read-string))))
(test-util/decode-body (core/app {:request-method :get :uri "/api/add" :query-string query})))
(deftest add-route-test
(testing "integers"
@@ -31,7 +29,7 @@
(let [resp (core/app {:request-method :get :uri "/api/add" :query-string "x=2&y=3"
:headers {"accept" "text/html,application/xhtml+xml,*/*;q=0.8"}})]
(is (= "text/plain; charset=utf-8" (get-in resp [:headers "Content-Type"])))
(is (= {:result 5} (edn/read-string (:body resp))))))
(is (= {:result 5} (:body (test-util/decode-body resp))))))
(testing "programmatic clients still get application/edn"
(let [resp (core/app {:request-method :get :uri "/api/add" :query-string "x=2&y=3"
:headers {"accept" "*/*"}})]
+4 -4
View File
@@ -1,8 +1,8 @@
(ns tbd.nrepl.routes-test
(:require [clojure.edn :as edn]
[clojure.test :refer [deftest is testing use-fixtures]]
(:require [clojure.test :refer [deftest is testing use-fixtures]]
[muuntaja.core :as m]
[tbd.auth.routes-test :as t]
[tbd.test-util :as test-util]
[tbd.nrepl.core :as nrepl])
(:import (java.io ByteArrayInputStream)
(java.net InetAddress ServerSocket)))
@@ -20,12 +20,12 @@
:headers (cond-> {"content-type" content-type}
cookie (assoc "cookie" cookie))}
body (assoc :body (ByteArrayInputStream. (.getBytes ^String body)))))))
(cond-> @resp (string? (:body @resp)) (update :body edn/read-string)))))
(test-util/decode-body @resp))))
(defn get-nrepl [app cookie]
(let [resp (app {:request-method :get :uri "/nrepl"
:headers (cond-> {} cookie (assoc "cookie" cookie))})]
(cond-> resp (string? (:body resp)) (update :body edn/read-string))))
(test-util/decode-body resp)))
(deftest anonymous-test
(let [app (t/make-app (t/gitea-stub "alice"))]
+13
View File
@@ -0,0 +1,13 @@
(ns tbd.test-util
(:require [muuntaja.core :as m]
[tbd.http :as http]))
(defn decode-body
"Reads a response's body back into data by the response's own Content-Type (JSON, EDN or
Transit). Bodies that aren't one of those, like text/plain, are left alone."
[resp]
(let [content-type (get-in resp [:headers "Content-Type"] "")
body (:body resp)]
(if (and body (not (string? body)) (re-find #"json|edn|transit" content-type))
(update resp :body #(m/decode http/muuntaja content-type %))
resp)))
+41
View File
@@ -0,0 +1,41 @@
(ns tbd.todo.core-test
(:require [clojure.test :refer [deftest is testing]]
[tbd.todo.core :as todo]))
(deftest validate-title-test
(testing "trims"
(is (= {:ok "buy milk"} (todo/validate-title " buy milk\n"))))
(testing "blank or missing"
(doseq [title [nil "" " " "\t\n"]]
(is (= {:error "title must not be blank"} (todo/validate-title title)) (pr-str title))))
(testing "length is checked after trimming"
(let [longest (apply str (repeat todo/max-title-length "x"))]
(is (= {:ok longest} (todo/validate-title (str " " longest " "))))
(is (contains? (todo/validate-title (str longest "x")) :error)))))
(deftest new-todo-test
(is (= {:ok {:owner-id 7 :title "buy milk" :done false :created-at 1000 :updated-at 1000}}
(todo/new-todo 7 " buy milk " 1000)))
(is (= {:error "title must not be blank"} (todo/new-todo 7 " " 1000))))
(def stored {:id 1 :owner-id 7 :title "buy milk" :done false :created-at 1000 :updated-at 1000})
(deftest apply-changes-test
(testing "title"
(is (= {:ok (assoc stored :title "buy oat milk" :updated-at 2000)}
(todo/apply-changes stored {:title " buy oat milk "} 2000))))
(testing "done, both ways"
(let [done (:ok (todo/apply-changes stored {:done true} 2000))]
(is (= (assoc stored :done true :updated-at 2000) done))
(is (= (assoc stored :updated-at 3000) (:ok (todo/apply-changes done {:done false} 3000))))))
(testing "both at once"
(is (= {:ok (assoc stored :title "x" :done true :updated-at 2000)}
(todo/apply-changes stored {:title "x" :done true} 2000))))
(testing "an invalid title changes nothing"
(is (= {:error "title must not be blank"} (todo/apply-changes stored {:title " " :done true} 2000))))
(testing "only :title and :done can change; the rest of the todo is untouched"
(is (= {:ok (assoc stored :done true :updated-at 2000)}
(todo/apply-changes stored {:done true :id 99 :owner-id 8 :created-at 0} 2000))))
(testing "no changes"
(doseq [changes [{} {:id 99} nil]]
(is (contains? (todo/apply-changes stored changes 2000) :error) (pr-str changes)))))
+69
View File
@@ -0,0 +1,69 @@
(ns tbd.todo.db-test
(:require [clojure.java.io :as io]
[clojure.test :refer [deftest is testing]]
[tbd.todo.db :as db])
(:import (java.nio.file Files)
(java.nio.file.attribute FileAttribute)))
(defn temp-db-path
"A path for a database file inside a fresh temp directory (which `open` has to create a
subdirectory of, as with the default data/todo.db)."
[]
(str (Files/createTempDirectory "tbd-todo" (make-array FileAttribute 0)) "/data/todo.db"))
(defn todo [owner-id title]
{:owner-id owner-id :title title :done false :created-at 1000 :updated-at 1000})
(deftest env-config-test
(is (= {:db-path "data/todo.db"} (db/env-config {})))
(is (= {:db-path "data/todo.db"} (db/env-config {"TODO_DB_PATH" " "})))
(is (= {:db-path "/var/todo.db"} (db/env-config {"TODO_DB_PATH" " /var/todo.db "}))))
(deftest open-test
(testing "creates the file and its directory"
(let [path (temp-db-path)]
(db/open path)
(is (.exists (io/file path)))))
(testing "reopening keeps the data"
(let [path (temp-db-path)
saved (db/insert! (db/open path) (todo 7 "buy milk"))]
(is (= [saved] (db/list-todos (db/open path) 7)))))
(testing "a path is required"
(doseq [path [nil "" " "]]
(is (thrown? clojure.lang.ExceptionInfo (db/open path))))))
(deftest crud-test
(let [ds (db/open (temp-db-path))
saved (db/insert! ds (todo 7 "buy milk"))
id (:id saved)]
(testing "insert returns the stored todo with an id"
(is (= (assoc (todo 7 "buy milk") :id id) saved))
(is (not= id (:id (db/insert! ds (todo 7 "walk dog"))))))
(testing "find and list"
(is (= saved (db/find-todo ds 7 id)))
(is (= ["buy milk" "walk dog"] (map :title (db/list-todos ds 7)))))
(testing "update"
(is (= (assoc saved :title "buy oat milk" :done true :updated-at 2000)
(db/update! ds 7 (assoc saved :title "buy oat milk" :done true :updated-at 2000))))
(is (= true (:done (db/find-todo ds 7 id)))))
(testing "missing ids"
(is (nil? (db/find-todo ds 7 999)))
(is (nil? (db/update! ds 7 (assoc saved :id 999))))
(is (false? (db/delete! ds 7 999))))
(testing "delete"
(is (true? (db/delete! ds 7 id)))
(is (nil? (db/find-todo ds 7 id)))
(is (false? (db/delete! ds 7 id))))))
(deftest owner-scoping-test
(let [ds (db/open (temp-db-path))
mine (db/insert! ds (todo 7 "mine"))
theirs (db/insert! ds (todo 8 "theirs"))]
(is (= [mine] (db/list-todos ds 7)))
(is (= [theirs] (db/list-todos ds 8)))
(is (= [] (db/list-todos ds 9)))
(testing "another owner's todo can't be found, changed, or deleted by its id"
(is (nil? (db/find-todo ds 8 (:id mine))))
(is (nil? (db/update! ds 8 (assoc mine :title "hijacked"))))
(is (false? (db/delete! ds 8 (:id mine))))
(is (= mine (db/find-todo ds 7 (:id mine)))))))
+106
View File
@@ -0,0 +1,106 @@
(ns tbd.todo.routes-test
(:require [clojure.java.io :as io]
[clojure.test :refer [deftest is testing]]
[tbd.auth.routes-test :as t]
[tbd.test-util :as test-util]
[tbd.todo.db-test :as db-test])
(:import (java.io ByteArrayInputStream)))
(defn request
"`body` is a JSON string (or nil). The response body is decoded by its Content-Type."
[app method uri cookie & [body]]
(let [resp (app (cond-> {:request-method method :uri uri :headers (cond-> {} cookie (assoc "cookie" cookie))}
body (-> (assoc :body (ByteArrayInputStream. (.getBytes ^String body)))
(assoc-in [:headers "content-type"] "application/json"))))]
(test-util/decode-body resp)))
(defn app-for [db-path login id]
(t/make-app (t/gitea-stub login id) :todo {:db-path db-path}))
(def ^:private todos "/api/todos")
(deftest anonymous-test
(let [path (db-test/temp-db-path)
app (app-for path "alice" 7)]
(doseq [[method uri body] [[:get todos nil]
[:post todos "{\"title\":\"x\"}"]
[:post todos "nope"]
[:get (str todos "/1") nil]
[:patch (str todos "/1") "{\"done\":true}"]
[:delete (str todos "/1") nil]]]
(let [resp (request app method uri nil body)]
(is (= 401 (:status resp)) (str method " " uri))
(is (= {:error "login required"} (:body resp)))))
(is (not (.exists (io/file path))) "the database isn't even opened")))
(deftest lifecycle-test
(let [app (app-for (db-test/temp-db-path) "alice" 7)
cookie (t/login! app)]
(testing "starts empty"
(let [resp (request app :get todos cookie)]
(is (= 200 (:status resp)))
(is (= "application/edn; charset=utf-8" (get-in resp [:headers "Content-Type"])))
(is (= {:todos []} (:body resp)))))
(testing "create"
(let [resp (request app :post todos cookie "{\"title\":\" buy milk \"}")
{:keys [id] :as created} (:body resp)]
(is (= 201 (:status resp)))
(is (= {:owner-id 7 :title "buy milk" :done false} (select-keys created [:owner-id :title :done])))
(is (pos? id))
(testing "get"
(is (= created (:body (request app :get (str todos "/" id) cookie)))))
(testing "patch"
(let [resp (request app :patch (str todos "/" id) cookie "{\"done\":true}")]
(is (= 200 (:status resp)))
(is (= (dissoc (assoc created :done true) :updated-at) (dissoc (:body resp) :updated-at))))
(is (= "oat milk" (:title (:body (request app :patch (str todos "/" id) cookie "{\"title\":\"oat milk\"}"))))))
(testing "list"
(request app :post todos cookie "{\"title\":\"walk dog\"}")
(is (= ["oat milk" "walk dog"] (map :title (:todos (:body (request app :get todos cookie)))))))
(testing "delete"
(is (= {:status 200 :body {:deleted true}}
(select-keys (request app :delete (str todos "/" id) cookie) [:status :body])))
(is (= 404 (:status (request app :get (str todos "/" id) cookie))))
(is (= 404 (:status (request app :delete (str todos "/" id) cookie))))
(is (= ["walk dog"] (map :title (:todos (:body (request app :get todos cookie)))))))))))
(deftest validation-test
(let [app (app-for (db-test/temp-db-path) "alice" 7)
cookie (t/login! app)
created (:body (request app :post todos cookie "{\"title\":\"keep\"}"))
one (str todos "/" (:id created))]
(testing "bad titles on create"
(doseq [body ["{\"title\":\"\"}" "{\"title\":\" \"}" "{}" "{\"title\":5}" "{\"title\":null}" "nope"
(format "{\"title\":\"%s\"}" (apply str (repeat 201 "x")))]]
(let [resp (request app :post todos cookie body)]
(is (= 400 (:status resp)) body)
(is (contains? (:body resp) :error)))))
(testing "bad changes"
(doseq [body ["{}" "{\"title\":\" \"}" "{\"done\":\"yes\"}" "{\"id\":99}"]]
(is (= 400 (:status (request app :patch one cookie body))) body))
(is (= 400 (:status (request app :patch one cookie))) "no body"))
(testing "a non-numeric id"
(doseq [method [:get :patch :delete]]
(is (= 400 (:status (request app method (str todos "/abc") cookie "{\"done\":true}"))) (str method))))
(is (= [created] (:todos (:body (request app :get todos cookie)))) "nothing above changed the todo")))
(deftest per-user-scoping-test
(let [path (db-test/temp-db-path)
alice-app (app-for path "alice" 7)
bob-app (app-for path "bob" 8)
alice (t/login! alice-app)
bob (t/login! bob-app)
{:keys [id] :as mine} (:body (request alice-app :post todos alice "{\"title\":\"alice's\"}"))
one (str todos "/" id)]
(testing "each user lists only their own"
(is (= [] (:todos (:body (request bob-app :get todos bob)))))
(request bob-app :post todos bob "{\"title\":\"bob's\"}")
(is (= ["alice's"] (map :title (:todos (:body (request alice-app :get todos alice))))))
(is (= ["bob's"] (map :title (:todos (:body (request bob-app :get todos bob)))))))
(testing "someone else's todo is indistinguishable from a missing one"
(let [missing (request bob-app :get (str todos "/999999") bob)]
(doseq [[method body] [[:get nil] [:patch "{\"title\":\"hijacked\"}"] [:delete nil]]]
(let [resp (request bob-app method one bob body)]
(is (= 404 (:status resp)) (str method))
(is (= (:body missing) (:body resp)))))))
(is (= mine (:body (request alice-app :get one alice))) "alice's todo is untouched")))