diff --git a/README.md b/README.md index 2baadc1..72005db 100644 --- a/README.md +++ b/README.md @@ -9,11 +9,14 @@ endpoints : * `POST /items`: creates an item * `GET /items`: returns the list of items -## Usage +## Running and testing This project uses Leiningen. Assuming you already have Leiningen installed, running the API should be as easy as cloning the repo and issuing `lein run`. By -default, the server listens on port 3000. +default, the server listens on port 3000. To explore the API using Swagger UI, +you can head to . + +To run the unit and integration tests: `lein test` ## Structure @@ -48,3 +51,4 @@ The following links proved more than useful when working on this assignment: * * * +* diff --git a/project.clj b/project.clj index cea8d79..d8dfa3b 100644 --- a/project.clj +++ b/project.clj @@ -12,7 +12,8 @@ [metosin/reitit-swagger-ui "0.10.0"] [metosin/muuntaja "0.6.11"] [com.github.seancorfield/next.jdbc "1.3.1086"] - [org.xerial/sqlite-jdbc "3.51.1.0"]] + [org.xerial/sqlite-jdbc "3.51.1.0"] + [org.clojure/data.json "2.5.1"]] :main ^:skip-aot yohoho.app :target-path "target/%s" :profiles {:uberjar {:aot :all diff --git a/test/yohoho/db_test.clj b/test/yohoho/db_test.clj new file mode 100644 index 0000000..be0c264 --- /dev/null +++ b/test/yohoho/db_test.clj @@ -0,0 +1,106 @@ +(ns yohoho.db-test + (:require + [clojure.test :refer :all] + [next.jdbc :as jdbc] + [yohoho.db :as db])) + +;; In-memory database setup --------------------------------------- +(def schema-sql + "CREATE TABLE items ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + email TEXT NOT NULL + );") + +(defn with-memory-db [f] + (let [conn (jdbc/get-connection + {:dbtype "sqlite" + :dbname ":memory:"})] + ;; create schema on the SAME connection + (jdbc/execute! conn [schema-sql]) + + ;; force db.clj to reuse this connection + (with-redefs [db/db conn] + (f)) + + (.close conn))) + +(use-fixtures :each with-memory-db) + +;; Tests ---------------------------------------------------------- +(deftest test-init + (testing "Database initialization creates items table" + ;; Drop the table first to test initialization + (try + (jdbc/execute! db/db ["DROP TABLE IF EXISTS items"]) + (catch Exception _)) + (db/init!) + ;; Verify table exists by trying to query it + (is (vector? (db/get-all-items))))) + + +(deftest test-store-item + (testing "Storing item returns an ID" + (let [id (db/store-item! "Test Item" "test@example.com")] + (is (number? id)) + (is (> id 0)))) + + (testing "Stored item can be retrieved" + (let [id (db/store-item! "Test Item" "test@example.com") + item (db/get-item id)] + (is (= "Test Item" (:name item))) + (is (= "test@example.com" (:email item))) + (is (= id (:id item))))) + + (testing "Multiple items get unique IDs" + (let [id1 (db/store-item! "Item 1" "item1@example.com") + id2 (db/store-item! "Item 2" "item2@example.com")] + (is (not= id1 id2))))) + + +(deftest test-get-item + (testing "Getting non-existent item returns nil" + (is (nil? (db/get-item 999)))) + + (testing "Getting existing item returns map with correct keys" + (let [id (db/store-item! "Test" "test@example.com") + item (db/get-item id)] + (is (map? item)) + (is (contains? item :id)) + (is (contains? item :name)) + (is (contains? item :email)) + ;; Keys should be unqualified + (is (not (qualified-keyword? :id))) + (is (not (qualified-keyword? :name))) + (is (not (qualified-keyword? :email))))) + + (testing "Retrieved item has correct values" + (let [id (db/store-item! "My Item" "myitem@example.com") + item (db/get-item id)] + (is (= id (:id item))) + (is (= "My Item" (:name item))) + (is (= "myitem@example.com" (:email item)))))) + + +(deftest test-get-all-items + (testing "Empty database returns empty vector" + (is (= [] (db/get-all-items)))) + + (testing "Database with items returns all items" + (db/store-item! "Item 1" "item1@example.com") + (db/store-item! "Item 2" "item2@example.com") + (db/store-item! "Item 3" "item3@example.com") + (let [items (db/get-all-items)] + (is (= 3 (count items))) + (is (every? map? items)) + (is (every? #(contains? % :id) items)) + (is (every? #(contains? % :name) items)) + (is (every? #(contains? % :email) items)))) + + (testing "Items are returned with unqualified keys" + (db/store-item! "Test" "test@example.com") + (let [items (db/get-all-items) + item (first items)] + (is (not (qualified-keyword? :id))) + (is (not (qualified-keyword? :name))) + (is (not (qualified-keyword? :email)))))) diff --git a/test/yohoho/handlers_test.clj b/test/yohoho/handlers_test.clj new file mode 100644 index 0000000..f111ef8 --- /dev/null +++ b/test/yohoho/handlers_test.clj @@ -0,0 +1,65 @@ +(ns yohoho.handlers-test + (:require [clojure.test :refer :all] + [yohoho.handlers :as handler] + [yohoho.db :as db])) + +(deftest test-ahoy + (testing "Health check endpoint returns 200" + (let [response (handler/ahoy {})] + (is (= 200 (:status response))) + (is (string? (:body response))) + (is (re-find #"Ahoy" (:body response)))))) + +(deftest test-get-items + (testing "Getting all items returns proper structure" + (with-redefs [db/get-all-items (fn [] [{:id 1 :name "Test" :email "test@example.org"} + {:id 2 :name "Test2" :email "test2@example.org"}])] + (let [response (handler/get-items {})] + (is (= 200 (:status response))) + (is (map? (:body response))) + (is (vector? (get-in response [:body :items]))) + (is (= 2 (count (get-in response [:body :items]))))))) + + (testing "Getting items with empty database" + (with-redefs [db/get-all-items (fn [] [])] + (let [response (handler/get-items {})] + (is (= 200 (:status response))) + (is (= [] (get-in response [:body :items]))))))) + +(deftest test-get-item + (testing "Getting existing item returns 200" + (with-redefs [db/get-item (fn [id] + (when (= id 1) + {:id 1 :name "Test" :email "test@example.org"}))] + (let [request {:path-params {:id 1}} + response (handler/get-item request)] + (is (= 200 (:status response))) + (is (= {:id 1 :name "Test" :email "test@example.org"} (:body response)))))) + + (testing "Getting non-existent item returns 404" + (with-redefs [db/get-item (fn [_] nil)] + (let [request {:path-params {:id 999}} + response (handler/get-item request)] + (is (= 404 (:status response))) + (is (= {:error "Not Found"} (:body response))))))) + +(deftest test-create-item + (testing "Creating item with valid data returns 201" + (with-redefs [db/store-item! (fn [_name _email] 42) + db/get-item (fn [id] + (when (= id 42) + {:id 42 :name "New Item" :email "new@example.org"}))] + (let [request {:body-params {:name "New Item" :email "new@example.org"}} + response (handler/create-item request)] + (is (= 201 (:status response))) + (is (= "/item/42" (get-in response [:headers "Location"]))) + (is (= {:id 42 :name "New Item" :email "new@example.org"} (:body response)))))) + + (testing "Created item has all expected fields" + (with-redefs [db/store-item! (fn [_name _email] 1) + db/get-item (fn [id] {:id id :name "Test" :email "test@example.org"})] + (let [request {:body-params {:name "Test" :email "test@example.org"}} + response (handler/create-item request)] + (is (contains? (:body response) :id)) + (is (contains? (:body response) :name)) + (is (contains? (:body response) :email)))))) \ No newline at end of file diff --git a/test/yohoho/helpers_test.clj b/test/yohoho/helpers_test.clj new file mode 100644 index 0000000..4aae616 --- /dev/null +++ b/test/yohoho/helpers_test.clj @@ -0,0 +1,35 @@ +(ns yohoho.helpers-test + (:require [clojure.test :refer :all] + [yohoho.helpers :as helpers])) + +(deftest test-post-payload-content-type-json-middleware + (let [mock-handler (fn [_request] {:status 200 :body "Success"}) + wrapped-handler (helpers/post-payload-content-type-json-middleware mock-handler)] + + (testing "GET requests pass through without content-type check" + (let [request {:request-method :get} + response (wrapped-handler request)] + (is (= 200 (:status response))) + (is (= "Success" (:body response))))) + + (testing "POST with application/json passes through" + (let [request {:request-method :post + :headers {"content-type" "application/json"}} + response (wrapped-handler request)] + (is (= 200 (:status response))) + (is (= "Success" (:body response))))) + + (testing "POST without content-type returns 415" + (let [request {:request-method :post + :headers {}} + response (wrapped-handler request)] + (is (= 415 (:status response))) + (is (= "Unsupported Media Type" (get-in response [:body :error]))) + (is (= "Content-Type must be application/json" (get-in response [:body :message]))))) + + (testing "POST with wrong content-type returns 415" + (let [request {:request-method :post + :headers {"content-type" "application/xml"}} + response (wrapped-handler request)] + (is (= 415 (:status response))) + (is (= "Unsupported Media Type" (get-in response [:body :error]))))))) diff --git a/test/yohoho/integration_test.clj b/test/yohoho/integration_test.clj new file mode 100644 index 0000000..05624b5 --- /dev/null +++ b/test/yohoho/integration_test.clj @@ -0,0 +1,196 @@ +(ns yohoho.integration-test + (:require [clojure.test :refer :all] + [yohoho.app :as app] + [yohoho.db :as db] + [next.jdbc :as jdbc] + [clojure.data.json :as json])) + + +;; In-memory database setup --------------------------------------- +(def schema-sql + "CREATE TABLE items ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + email TEXT NOT NULL + );") + +(defn with-memory-db [f] + (let [conn (jdbc/get-connection + {:dbtype "sqlite" + :dbname ":memory:"})] + ;; create schema on the SAME connection + (jdbc/execute! conn [schema-sql]) + + ;; force db.clj to reuse this connection + (with-redefs [db/db conn] + (f)) + + (.close conn))) + +(use-fixtures :each with-memory-db) + + +;; Helpers -------------------------------------------------------- +(defn parse-json-body [response] + (when-let [body (:body response)] + (cond + (string? body) + (json/read-str body :key-fn keyword) + + (instance? java.io.InputStream body) + (json/read (java.io.InputStreamReader. body) :key-fn keyword) + + :else + body))) + +(defn make-request + "Helper to make a request to the app" + [method uri & {:keys [body headers]}] + (let [request {:request-method method + :uri uri + :headers (merge {"content-type" "application/json"} headers)} + request (if body + (assoc request :body-params body) + request)] + (app/app request))) + + +;; Tests ---------------------------------------------------------- +(deftest test-health-check + (testing "Health check endpoint returns 200" + (let [response (make-request :get "/ahoy")] + (is (= 200 (:status response))) + (is (string? (:body response)))))) + +(deftest test-create-and-retrieve-item + (testing "Creating an item and retrieving it" + (let [item-data {:name "Integration Test Item" :email "integration@test.com"} + create-response (make-request :post "/items" :body item-data)] + + ;; Verify creation response + (is (= 201 (:status create-response))) + (let [created-item (parse-json-body create-response)] + (is (number? (:id created-item))) + (is (= "Integration Test Item" (:name created-item))) + (is (= "integration@test.com" (:email created-item))) + + ;; Verify Location header + (is (= (str "/item/" (:id created-item)) + (get-in create-response [:headers "Location"]))) + + ;; Retrieve the item by ID + (let [get-response (make-request :get (str "/item/" (:id created-item))) + retrieved-item (parse-json-body get-response)] + (is (= 200 (:status get-response))) + (is (= created-item retrieved-item))))))) + +(deftest test-get-all-items + (testing "Getting all items returns empty list initially" + (let [response (make-request :get "/items") + body (parse-json-body response)] + (is (= 200 (:status response))) + (is (= [] (:items body))))) + + (testing "Getting all items after creating some" + ;; Create multiple items + (make-request :post "/items" :body {:name "Item 1" :email "item1@test.com"}) + (make-request :post "/items" :body {:name "Item 2" :email "item2@test.com"}) + (make-request :post "/items" :body {:name "Item 3" :email "item3@test.com"}) + + (let [response (make-request :get "/items") + body (parse-json-body response)] + (is (= 200 (:status response))) + (is (= 3 (count (:items body)))) + (is (every? #(contains? % :id) (:items body))) + (is (every? #(contains? % :name) (:items body))) + (is (every? #(contains? % :email) (:items body)))))) + +(deftest test-get-nonexistent-item + (testing "Getting non-existent item returns 404" + (let [response (make-request :get "/item/999") + body (parse-json-body response)] + (is (= 404 (:status response))) + (is (= "Not Found" (:error body)))))) + +(deftest test-create-item-validation + (testing "Creating item without email fails validation" + (let [response (make-request :post "/items" :body {:name "Test"})] + (is (= 400 (:status response))))) + + (testing "Creating item without name fails validation" + (let [response (make-request :post "/items" :body {:email "test@example.com"})] + (is (= 400 (:status response))))) + + (testing "Creating item with invalid email fails validation" + (let [response (make-request :post "/items" :body {:name "Test" :email "not-an-email"})] + (is (= 400 (:status response))))) + + (testing "Creating item with empty payload fails validation" + (let [response (make-request :post "/items" :body {})] + (is (= 400 (:status response)))))) + +(deftest test-content-type-validation + (testing "POST without content-type returns 415" + (let [response (make-request :post "/items" :body {:name "Test" :email "test@test.com"} + :headers {"content-type" nil})] + (is (= 415 (:status response))))) + + (testing "POST with wrong content-type returns 415" + (let [response (make-request :post "/items" :body {:name "Test" :email "test@test.com"} + :headers {"content-type" "application/xml"})] + (is (= 415 (:status response)))))) + +(deftest test-invalid-routes + (testing "Non-existent route returns 404" + (let [response (make-request :get "/nonexistent")] + (is (= 404 (:status response)))))) + +(deftest test-openapi-endpoint + (testing "OpenAPI documentation is available" + (let [response (make-request :get "/openapi.json")] + (is (= 200 (:status response))) + (let [openapi-doc (parse-json-body response)] + (is (= "Yo-ho-ho API" (get-in openapi-doc [:info :title]))) + (is (contains? openapi-doc :paths)))))) + +(deftest test-item-id-coercion + (testing "Item ID path parameter is coerced to integer" + ;; Create an item first + (let [create-response (make-request :post "/items" + :body {:name "Test" :email "test@test.com"}) + created-item (parse-json-body create-response) + item-id (:id created-item)] + + ;; Request with string ID should work (coerced to int) + (let [response (make-request :get (str "/item/" item-id))] + (is (= 200 (:status response)))))) + + (testing "Invalid ID format returns 400" + (let [response (make-request :get "/item/not-a-number")] + (is (= 400 (:status response)))))) + +(deftest test-multiple-items-workflow + (testing "Complete workflow: create multiple items, retrieve all, retrieve individual" + (let [items-to-create [{:name "Alice" :email "alice@example.com"} + {:name "Bob" :email "bob@example.com"} + {:name "Charlie" :email "charlie@example.com"}] + created-ids (atom [])] + + ;; Create items + (doseq [item items-to-create] + (let [response (make-request :post "/items" :body item) + created (parse-json-body response)] + (is (= 201 (:status response))) + (swap! created-ids conj (:id created)))) + + ;; Verify all items in list + (let [list-response (make-request :get "/items") + all-items (:items (parse-json-body list-response))] + (is (= 3 (count all-items))) + (is (= #{"Alice" "Bob" "Charlie"} (set (map :name all-items))))) + + ;; Verify each item individually + (doseq [id @created-ids] + (let [response (make-request :get (str "/item/" id))] + (is (= 200 (:status response))) + (is (= id (:id (parse-json-body response))))))))) diff --git a/test/yohoho/schemas_test.clj b/test/yohoho/schemas_test.clj new file mode 100644 index 0000000..a17095f --- /dev/null +++ b/test/yohoho/schemas_test.clj @@ -0,0 +1,97 @@ +(ns yohoho.schemas-test + (:require [clojure.test :refer :all] + [yohoho.schemas :as s] + [malli.core :as m])) + +(deftest test-email-validation + (testing "Valid email addresses" + (is (m/validate s/Email "test@example.com")) + (is (m/validate s/Email "user.name@example.com")) + (is (m/validate s/Email "user+tag@example.co.uk")) + (is (m/validate s/Email "user_123@test-domain.com")) + (is (m/validate s/Email "a@b.c"))) + + (testing "Invalid email addresses" + (is (not (m/validate s/Email "notanemail"))) + (is (not (m/validate s/Email "@example.com"))) + (is (not (m/validate s/Email "user@"))) + (is (not (m/validate s/Email "user @example.com"))) + (is (not (m/validate s/Email "user@example"))) + (is (not (m/validate s/Email ""))))) + +(deftest test-item-input-schema + (testing "Valid item input" + (is (m/validate s/ItemInput {:name "Test Item" :email "test@example.com"})) + (is (m/validate s/ItemInput {:name "A" :email "a@b.c"}))) + + (testing "Invalid item input - missing fields" + (is (not (m/validate s/ItemInput {:name "Test"}))) + (is (not (m/validate s/ItemInput {:email "test@example.com"}))) + (is (not (m/validate s/ItemInput {})))) + + (testing "Invalid item input - wrong types" + (is (not (m/validate s/ItemInput {:name 123 :email "test@example.com"}))) + (is (not (m/validate s/ItemInput {:name "Test" :email "not-an-email"})))) + + (testing "Extra fields are allowed (open map)" + (is (m/validate s/ItemInput {:name "Test" :email "test@example.com" :extra "field"})))) + +(deftest test-item-schema + (testing "Valid item" + (is (m/validate s/Item {:id 1 :name "Test" :email "test@example.com"})) + (is (m/validate s/Item {:id 999 :name "Long Name Here" :email "complex.email+tag@example.co.uk"}))) + + (testing "Invalid item - missing fields" + (is (not (m/validate s/Item {:id 1 :name "Test"}))) + (is (not (m/validate s/Item {:id 1 :email "test@example.com"}))) + (is (not (m/validate s/Item {:name "Test" :email "test@example.com"})))) + + (testing "Invalid item - wrong types" + (is (not (m/validate s/Item {:id "not-a-number" :name "Test" :email "test@example.com"}))) + (is (not (m/validate s/Item {:id 1.5 :name "Test" :email "test@example.com"}))) + (is (not (m/validate s/Item {:id 1 :name "Test" :email "invalid-email"}))))) + +(deftest test-items-response-schema + (testing "Valid items response" + (is (m/validate s/ItemsResponse {:items []})) + (is (m/validate s/ItemsResponse {:items [{:id 1 :name "Test" :email "test@example.com"}]})) + (is (m/validate s/ItemsResponse {:items [{:id 1 :name "Test1" :email "test1@example.com"} + {:id 2 :name "Test2" :email "test2@example.com"}]}))) + + (testing "Invalid items response - not a vector" + (is (not (m/validate s/ItemsResponse {:items {:id 1 :name "Test" :email "test@example.com"}}))) + (is (not (m/validate s/ItemsResponse {:items "not-a-vector"})))) + + (testing "Invalid items response - missing items key" + (is (not (m/validate s/ItemsResponse {}))) + (is (not (m/validate s/ItemsResponse {:data []})))) + + (testing "Invalid items response - invalid item in vector" + (is (not (m/validate s/ItemsResponse {:items [{:id 1 :name "Test"}]}))))) + +(deftest test-error-response-schema + (testing "Valid error response with message" + (is (m/validate s/ErrorResponse {:error "Not Found" :message "Item does not exist"}))) + + (testing "Valid error response without message" + (is (m/validate s/ErrorResponse {:error "Not Found"}))) + + (testing "Invalid error response - missing error" + (is (not (m/validate s/ErrorResponse {:message "Something went wrong"}))) + (is (not (m/validate s/ErrorResponse {})))) + + (testing "Invalid error response - wrong types" + (is (not (m/validate s/ErrorResponse {:error 404 :message "Not Found"}))) + (is (not (m/validate s/ErrorResponse {:error "Not Found" :message 123}))))) + +(deftest test-error-400-response-schema + (testing "Valid 400 error response" + (is (m/validate s/Error400Response {:value {} + :type "reitit.coercion/request-coercion" + :coercion "malli" + :in ["request" "body-params"] + :humanized {:email ["should be a valid email"]}}))) + + (testing "Invalid 400 error - missing required fields" + (is (not (m/validate s/Error400Response {:value {}}))) + (is (not (m/validate s/Error400Response {:type "type" :coercion "malli" :in [] :humanized {}}))))) \ No newline at end of file