| 1 | ## Unit tests: the store on its own, then the routes with fake requests. |
| 2 | ## Run with `make test`. |
| 3 | |
| 4 | import std/[unittest, os, tempfiles, strutils, uri] |
| 5 | include "../src/app.nim" |
| 6 | |
| 7 | let dir = createTempDir("todo-nim-test", "") |
| 8 | var dbCount = 0 |
| 9 | |
| 10 | proc freshStore(): Store = |
| 11 | inc dbCount |
| 12 | store.open(dir / ("t" & $dbCount & ".db")) |
| 13 | |
| 14 | suite "store": |
| 15 | test "keeps each list to itself": |
| 16 | let s = freshStore() |
| 17 | let a = s.createList() |
| 18 | let b = s.createList() |
| 19 | s.add(a, "mine") |
| 20 | let id = s.todos(a)[0].id |
| 21 | check s.todos(b).len == 0 |
| 22 | check not s.toggle(b, id) |
| 23 | check not s.rename(b, id, "theirs") |
| 24 | check not s.destroy(b, id) |
| 25 | check s.todos(a) == @[Todo(id: id, title: "mine", completed: false)] |
| 26 | |
| 27 | test "filters, toggles all and clears completed": |
| 28 | let s = freshStore() |
| 29 | let l = s.createList() |
| 30 | for t in ["a", "b", "c"]: s.add(l, t) |
| 31 | check s.toggle(l, s.todos(l)[1].id) |
| 32 | check s.todos(l, fActive).len == 2 |
| 33 | check s.todos(l, fCompleted).len == 1 |
| 34 | s.toggleAll(l) |
| 35 | check s.counts(l) == (total: 3, completed: 3) |
| 36 | s.toggleAll(l) |
| 37 | check s.counts(l) == (total: 3, completed: 0) |
| 38 | discard s.toggle(l, s.todos(l)[0].id) |
| 39 | s.clearCompleted(l) |
| 40 | check s.todos(l).len == 2 |
| 41 | |
| 42 | test "keeps any Unicode and long titles exactly": |
| 43 | let s = freshStore() |
| 44 | let l = s.createList() |
| 45 | let long = "x".repeat(10_000) |
| 46 | s.add(l, "naïve 🚀 日本語 <b>") |
| 47 | s.add(l, long) |
| 48 | check s.todos(l)[0].title == "naïve 🚀 日本語 <b>" |
| 49 | check s.todos(l)[1].title == long |
| 50 | |
| 51 | test "reopening runs no migration twice and keeps data": |
| 52 | let path = dir / "reopen.db" |
| 53 | var s = store.open(path) |
| 54 | let l = s.createList() |
| 55 | s.add(l, "kept") |
| 56 | s.close() |
| 57 | s = store.open(path) |
| 58 | check s.todos(l)[0].title == "kept" |
| 59 | check s.schemaVersion == 1 |
| 60 | check fileExists(path & "-wal") |
| 61 | s.close() |
| 62 | |
| 63 | test "list ids are 128 random bits": |
| 64 | let a = newListId() |
| 65 | check a.len == 32 and isWellFormedListId(a) |
| 66 | check a != newListId() |
| 67 | check not isWellFormedListId("%%%") |
| 68 | |
| 69 | # Routes |
| 70 | |
| 71 | let app = App(store: freshStore(), origin: "http://127.0.0.1:9", secure: false) |
| 72 | |
| 73 | proc request(meth: HttpMethod, target: string, body = "", |
| 74 | headers: openArray[(string, string)] = []): Request = |
| 75 | Request(reqMethod: meth, url: parseUri(target), body: body, |
| 76 | headers: newHttpHeaders(headers)) |
| 77 | |
| 78 | proc headerOf(r: Reply, name: string): string = |
| 79 | for (k, v) in r.headers: |
| 80 | if k.cmpIgnoreCase(name) == 0: return v |
| 81 | |
| 82 | proc cookieOf(r: Reply): string = |
| 83 | r.headerOf("Set-Cookie").split(';')[0] |
| 84 | |
| 85 | proc post(target, body: string, cookie: string, |
| 86 | extra: openArray[(string, string)] = []): Reply = |
| 87 | app.route(request(HttpPost, target, body, @{"Cookie": cookie} & @extra)) |
| 88 | |
| 89 | suite "routes": |
| 90 | test "the first visit gets a cookie and an empty page": |
| 91 | let r = app.route(request(HttpGet, "/")) |
| 92 | check r.code == Http200 |
| 93 | let c = r.headerOf("Set-Cookie") |
| 94 | check c.startsWith("todo_list=") and "HttpOnly" in c and "SameSite=Lax" in c |
| 95 | check "Secure" notin c |
| 96 | check "<section class=\"main\">" notin r.body |
| 97 | check "Created by <a href=\"https://bakeoff.bfj.oncarlos.com\">the CARLOS bakeoff</a> · Nim" in r.body |
| 98 | |
| 99 | test "a garbled or unknown cookie gets a fresh list": |
| 100 | for bad in ["%%%", "0123456789abcdef0123456789abcdef"]: |
| 101 | let r = app.route(request(HttpGet, "/", headers = {"Cookie": "todo_list=" & bad})) |
| 102 | check r.code == Http200 |
| 103 | check r.cookieOf() != "todo_list=" & bad |
| 104 | |
| 105 | test "actions answer 303 back to the filter": |
| 106 | let cookie = app.route(request(HttpGet, "/")).cookieOf() |
| 107 | for (filter, path) in [("all", "/"), ("active", "/active"), |
| 108 | ("completed", "/completed"), ("bogus", "/"), ("", "/")]: |
| 109 | let r = post("/todos", "title=x&filter=" & filter, cookie) |
| 110 | check r.code == Http303 |
| 111 | check r.headerOf("Location") == path |
| 112 | |
| 113 | test "titles are trimmed, blank ones ignored, and shown as text": |
| 114 | let cookie = app.route(request(HttpGet, "/")).cookieOf() |
| 115 | discard post("/todos", "title=" & encodeUrl(" ") & "&filter=all", cookie) |
| 116 | discard post("/todos", "title=" & encodeUrl(" <img src=x> & \"q\" "), cookie) |
| 117 | let page = app.route(request(HttpGet, "/", headers = {"Cookie": cookie})).body |
| 118 | check page.count("<li data-id=") == 1 |
| 119 | check "<label><img src=x> & "q"</label>" in page |
| 120 | |
| 121 | test "titles are trimmed of Unicode whitespace too": |
| 122 | let cookie = app.route(request(HttpGet, "/")).cookieOf() |
| 123 | let list = cookie.split('=')[1] |
| 124 | let spaces = "\u00A0\u3000\u2003\uFEFF" |
| 125 | discard post("/todos", "title=" & encodeUrl(spaces), cookie) |
| 126 | check app.store.todos(list).len == 0 |
| 127 | discard post("/todos", "title=" & encodeUrl(spaces & "naïve 🚀" & spaces), cookie) |
| 128 | check app.store.todos(list)[0].title == "naïve 🚀" |
| 129 | let id = $app.store.todos(list)[0].id |
| 130 | discard post("/todos/" & id & "/edit", "title=" & encodeUrl("\u00A0\u00A0"), cookie) |
| 131 | check app.store.todos(list).len == 0 |
| 132 | |
| 133 | test "a title that isn't valid UTF-8 answers 400 and adds nothing": |
| 134 | let cookie = app.route(request(HttpGet, "/")).cookieOf() |
| 135 | let list = cookie.split('=')[1] |
| 136 | for bad in ["%80", "%C3", "%E2%82", "%F0%9F%9A", "a%FFb"]: |
| 137 | check post("/todos", "title=" & bad, cookie).code == Http400 |
| 138 | check app.store.todos(list).len == 0 |
| 139 | discard post("/todos", "title=ok", cookie) |
| 140 | let id = $app.store.todos(list)[0].id |
| 141 | check post("/todos/" & id & "/edit", "title=%80", cookie).code == Http400 |
| 142 | check app.store.todos(list)[0].title == "ok" |
| 143 | |
| 144 | test "another list's id answers 404 and changes nothing": |
| 145 | let mine = app.route(request(HttpGet, "/")).cookieOf() |
| 146 | let theirs = app.route(request(HttpGet, "/")).cookieOf() |
| 147 | discard post("/todos", "title=mine", mine) |
| 148 | let list = mine.split('=')[1] |
| 149 | let id = app.store.todos(list)[0].id |
| 150 | for verb in ["toggle", "destroy", "edit"]: |
| 151 | check post("/todos/" & $id & "/" & verb, "title=x", theirs).code == Http404 |
| 152 | check post("/todos/nope/toggle", "", mine).code == Http404 |
| 153 | check app.store.todos(list)[0] == Todo(id: id, title: "mine", completed: false) |
| 154 | |
| 155 | test "a foreign Origin is refused; our own and none are allowed": |
| 156 | let cookie = app.route(request(HttpGet, "/")).cookieOf() |
| 157 | check post("/todos", "title=a", cookie, {"Origin": "https://evil.example"}).code == Http403 |
| 158 | check post("/todos", "title=a", cookie, {"Origin": "http://127.0.0.1:9"}).code == Http303 |
| 159 | check post("/todos", "title=a", cookie).code == Http303 |
| 160 | |
| 161 | test "editing: the edit view, renaming, and blank deletes": |
| 162 | let cookie = app.route(request(HttpGet, "/")).cookieOf() |
| 163 | discard post("/todos", "title=old", cookie) |
| 164 | let list = cookie.split('=')[1] |
| 165 | let id = $app.store.todos(list)[0].id |
| 166 | let page = app.route(request(HttpGet, "/active?edit=" & id, |
| 167 | headers = {"Cookie": cookie})).body |
| 168 | check "<li class=\"editing\" data-id=\"" & id & "\">" in page |
| 169 | check "action=\"/todos/" & id & "/edit\"" in page |
| 170 | discard post("/todos/" & id & "/edit", "title=" & encodeUrl(" new "), cookie) |
| 171 | check app.store.todos(list)[0].title == "new" |
| 172 | discard post("/todos/" & id & "/edit", "title=+++", cookie) |
| 173 | check app.store.todos(list).len == 0 |
| 174 | |
| 175 | test "the page's JavaScript gets a fragment instead of a redirect": |
| 176 | let cookie = app.route(request(HttpGet, "/")).cookieOf() |
| 177 | let r = post("/todos", "title=one&filter=active", cookie, {"X-Fragment": "1"}) |
| 178 | check r.code == Http200 |
| 179 | check r.body.strip.startsWith("<section class=\"main\">") |
| 180 | check "<strong>1</strong> item left" in r.body |
| 181 | check "<a class=\"selected\" href=\"/active\">" in r.body |
| 182 | |
| 183 | test "counter, toggle-all state and clear-completed": |
| 184 | let cookie = app.route(request(HttpGet, "/")).cookieOf() |
| 185 | discard post("/todos", "title=a", cookie) |
| 186 | discard post("/todos", "title=b", cookie) |
| 187 | var page = app.route(request(HttpGet, "/", headers = {"Cookie": cookie})).body |
| 188 | check "<strong>2</strong> items left" in page |
| 189 | check "clear-completed" notin page.split("<footer class=\"footer\">")[1].split("</footer>")[0] |
| 190 | discard post("/todos/toggle-all", "", cookie) |
| 191 | page = app.route(request(HttpGet, "/", headers = {"Cookie": cookie})).body |
| 192 | check "<strong>0</strong> items left" in page |
| 193 | check "class=\"toggle-all\" type=\"checkbox\" checked" in page |
| 194 | check "<button class=\"clear-completed\">Clear completed</button>" in page |
| 195 | |
| 196 | test "static files, health and version": |
| 197 | check app.route(request(HttpGet, "/static/base.css")).body == readFile("../../shared/base.css") |
| 198 | check app.route(request(HttpGet, "/static/index.css")).body == readFile("../../shared/index.css") |
| 199 | check app.route(request(HttpGet, "/healthz")).code == Http200 |
| 200 | check app.route(request(HttpGet, "/api/version")).body == appVersion |
| 201 | check app.route(request(HttpGet, "/nope")).code == Http404 |
| 202 | |
| 203 | app.store.close() |
| 204 | removeDir(dir) |
| 205 | |