package bm_test import ( "encoding/json" "net/http" "testing" "git.golem15.com/golem15/summercms/modules/surf" "git.golem15.com/jakub/sm-bm-plugin/models" ) // want is apiClient.send that fails unless the status is code. func (c apiClient) want(code int, method, path, body string) map[string]any { c.t.Helper() got, out := c.send(method, path, body) if got != code { c.t.Fatalf("%s %s = %d %v, want %d", method, path, got, out, code) } return out } func TestChecklistsAgainstDatabase(t *testing.T) { app, plugins, gdb := migrated(t) seedFixture(t, plugins, "testdata/seed.example.json") h, err := surf.Assemble(app, plugins) if err != nil { t.Fatalf("Assemble: %v", err) } aliceID := createUser(t, gdb, "alice@example.test") alice := apiClient{t: t, h: h, token: tokenFor(t, aliceID)} bob := apiClient{t: t, h: h, token: tokenFor(t, createUser(t, gdb, "bob@example.test"))} const base = "/_bm/api/v1" var meeting models.Meeting gdb.Order("number").Take(&meeting) var reading models.Reading gdb.Where("course_id IS NOT NULL").Order("id").Take(&reading) // Create, with validation of the zjazd and unknown fields. alice.want(http.StatusUnprocessableEntity, "POST", base+"/checklists", `{"title":"X","meeting_id":99999}`) alice.want(http.StatusUnprocessableEntity, "POST", base+"/checklists", `{"title":"X","user_id":1}`) alice.want(http.StatusUnprocessableEntity, "POST", base+"/checklists", `{}`) created := obj(alice.want(http.StatusCreated, "POST", base+"/checklists", `{"title":"Zjazd 1","meeting_id":`+jsonID(meeting.ID)+`}`)["data"]) cid := id(created["id"]) if obj(created["meeting"])["number"] != float64(meeting.Number) { t.Errorf("meeting = %v", created["meeting"]) } // Items: a task with an estimate and an item with a reading. alice.want(http.StatusUnprocessableEntity, "POST", base+"/checklists/"+cid+"/items", `{"title":"X","kind":"film"}`) alice.want(http.StatusUnprocessableEntity, "POST", base+"/checklists/"+cid+"/items", `{"title":"X","presentation_slug":"nie-ma"}`) alice.want(http.StatusUnprocessableEntity, "POST", base+"/checklists/"+cid+"/items", `{"title":"X","estimate_minutes":-5}`) task := obj(alice.want(http.StatusCreated, "POST", base+"/checklists/"+cid+"/items", `{"title":"Slajdy","estimate_minutes":60,"due_at":"2026-11-07 20:00"}`)["data"]) if task["kind"] != "zadanie" || task["done"] != false { t.Errorf("task = %v", task) } lektura := obj(alice.want(http.StatusCreated, "POST", base+"/checklists/"+cid+"/items", `{"title":"Lektura","kind":"lektura","reading_id":`+jsonID(reading.ID)+`,"estimate_minutes":30}`)["data"]) if lektura["sort_order"].(float64) <= task["sort_order"].(float64) { t.Errorf("new item not last: %v after %v", lektura["sort_order"], task["sort_order"]) } if obj(lektura["reading"])["title"] != reading.Title { t.Errorf("reading = %v", lektura["reading"]) } // Bob sees none of it and cannot touch it. if got := list(bob.ok("GET", base+"/checklists")); len(got) != 0 { t.Errorf("bob sees %v", got) } bob.want(http.StatusNotFound, "GET", base+"/checklists/"+cid, "") bob.want(http.StatusNotFound, "PATCH", base+"/checklists/"+cid, `{"title":"Mine"}`) bob.want(http.StatusNotFound, "POST", base+"/checklists/"+cid+"/items", `{"title":"X"}`) bob.want(http.StatusNotFound, "PUT", base+"/checklist-items/"+id(task["id"])+"/done", "") bob.want(http.StatusNotFound, "DELETE", base+"/checklist-items/"+id(task["id"]), "") bobList := obj(bob.want(http.StatusCreated, "POST", base+"/checklists", `{"title":"Bob"}`)["data"]) alice.want(http.StatusUnprocessableEntity, "PATCH", base+"/checklist-items/"+id(task["id"]), `{"checklist_id":`+id(bobList["id"])+`}`) // Done marks: a task gets done_at, an item with a reading marks the // reading read, and a reading read in the reading list shows done. done := obj(alice.ok("PUT", base+"/checklist-items/"+id(task["id"])+"/done")["data"]) if done["done"] != true || done["done_at"] == nil { t.Errorf("task done = %v", done) } alice.ok("PUT", base+"/checklist-items/"+id(lektura["id"])+"/done") var n int64 gdb.Model(&models.ReadingProgress{}).Where("reading_id = ? AND user_id = ?", reading.ID, aliceID).Count(&n) if n != 1 { t.Errorf("reading progress rows = %d, want 1", n) } detail := obj(alice.ok("GET", base+"/checklists/"+cid)["data"]) progress := obj(detail["progress"]) if progress["total"] != 2.0 || progress["done"] != 2.0 || progress["percent"] != 100.0 || progress["minutes_left"] != 0.0 || progress["minutes_total"] != 90.0 { t.Errorf("progress = %v", progress) } alice.ok("DELETE", "/_bm/api/v1/readings/"+jsonID(reading.ID)+"/progress") summary := list(alice.ok("GET", base+"/checklists?meeting="+jsonID(meeting.ID))) if len(summary) != 1 || obj(summary[0]["progress"])["done"] != 1.0 || obj(summary[0]["progress"])["minutes_left"] != 30.0 { t.Errorf("after unread: %v", summary) } if summary[0]["items"] != nil { t.Errorf("list carries items: %v", summary[0]["items"]) } undone := obj(alice.ok("DELETE", base+"/checklist-items/"+id(task["id"])+"/done")["data"]) if undone["done"] != false { t.Errorf("task undone = %v", undone) } // Patch, the token twin, and delete with cascade. patched := obj(alice.want(http.StatusOK, "PATCH", base+"/checklist-items/"+id(task["id"]), `{"title":"Slajdy v2","notes":""}`)["data"]) if patched["title"] != "Slajdy v2" || patched["estimate_minutes"] != 60.0 { t.Errorf("patched = %v", patched) } alice.want(http.StatusNoContent, "DELETE", base+"/checklist-items/"+id(task["id"]), "") alice.want(http.StatusNoContent, "DELETE", base+"/checklists/"+cid, "") if c := count(t, gdb, "jz_bm_checklist_items"); c != 0 { t.Errorf("items left = %d", c) } alice.want(http.StatusNotFound, "GET", base+"/checklists/"+cid, "") } func jsonID(id uint) string { b, _ := json.Marshal(id) return string(b) }