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:
Jakub Zych
2026-10-30 12:41:04 +01:00
parent 643f8cbf7c
commit d7cd5baedf
15 changed files with 1262 additions and 21 deletions

126
checklists_postgres_test.go Normal file
View 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)
}