feat: private checklists for zjazd preparation
Per-user checklists with items that may link a course, reading, deadline, material or own presentation deck. An item with a reading shares the reading's read mark. Student API routes with token twins, MCP tools, migrations, tests and README.
This commit is contained in:
126
checklists_postgres_test.go
Normal file
126
checklists_postgres_test.go
Normal file
@@ -0,0 +1,126 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user