test: cover material pins against postgres

This commit is contained in:
Jakub Zych
2026-11-09 13:15:44 +01:00
parent 341f6cd95a
commit bff92cca78
2 changed files with 190 additions and 1 deletions

View File

@@ -0,0 +1,189 @@
package bm_test
import (
"net/http"
"slices"
"testing"
"time"
"git.golem15.com/golem15/summercms/modules/surf"
"git.golem15.com/jakub/sm-bm-plugin/models"
)
// TestMaterialPinsAgainstDatabase checks personal pins: the pinned-first
// order of the plain list within the filters and across pages, the pinned
// flag on list rows, the detail and the course materials, that another
// user's list ignores the pins, the 404 for materials the caller cannot
// see, idempotent pin and unpin, that a pin leaves the material's revision
// and updated_at alone, and the cascade on material delete.
func TestMaterialPinsAgainstDatabase(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")
carolID := createUser(t, gdb, "carol@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 course models.Course
if err := gdb.Order("id").Take(&course).Error; err != nil {
t.Fatal(err)
}
insert := func(title, typ, published string, private bool, authorUserID *uint) uint {
t.Helper()
var id uint
err := gdb.Raw(`INSERT INTO jz_bm_materials (course_id, title, type, published_at, is_private, author_user_id)
VALUES (?, ?, ?, `+published+`, ?, ?) RETURNING id`, course.ID, title, typ, private, authorUserID).Scan(&id).Error
if err != nil {
t.Fatal(err)
}
return id
}
m1 := insert("M1", "notatka", "NOW() - INTERVAL '3 days'", false, nil)
m2 := insert("M2", "notatka", "NOW() - INTERVAL '2 days'", false, nil)
m3 := insert("M3", "pdf", "NOW() - INTERVAL '1 day'", false, nil)
mp := insert("MP", "notatka", "NOW() - INTERVAL '1 day'", true, &carolID)
md := insert("MD", "notatka", "NULL", false, nil)
type row struct {
id string
pinned bool
}
rowsOf := func(rows []map[string]any) []row {
out := make([]row, 0, len(rows))
for _, r := range rows {
pinned, ok := r["pinned"].(bool)
if !ok {
t.Errorf("row %v has no boolean pinned", r)
}
out = append(out, row{id(r["id"]), pinned})
}
return out
}
want := func(pairs ...any) []row {
out := make([]row, 0, len(pairs)/2)
for i := 0; i+1 < len(pairs); i += 2 {
out = append(out, row{jsonID(pairs[i].(uint)), pairs[i+1].(bool)})
}
return out
}
listOf := func(c apiClient, query string) []row {
t.Helper()
return rowsOf(list(c.ok("GET", base+"/materials"+query)))
}
check := func(name string, got, wantRows []row) {
t.Helper()
if !slices.Equal(got, wantRows) {
t.Errorf("%s = %v, want %v", name, got, wantRows)
}
}
pinPath := func(id uint) string { return base + "/materials/" + jsonID(id) + "/pin" }
pinnedAt := func(id uint) time.Time {
t.Helper()
var at time.Time
if err := gdb.Raw(`SELECT pinned_at FROM jz_bm_material_pins WHERE material_id = ? AND user_id = ?`, id, aliceID).Scan(&at).Error; err != nil {
t.Fatal(err)
}
return at
}
type version struct {
Revision uint
UpdatedAt time.Time
}
versionOf := func(id uint) version {
t.Helper()
var v version
if err := gdb.Raw(`SELECT revision, updated_at FROM jz_bm_materials WHERE id = ?`, id).Scan(&v).Error; err != nil {
t.Fatal(err)
}
return v
}
// (a) Nothing pinned: newest publication first.
check("alice before pins", listOf(alice, "?per_page=100"), want(m3, false, m2, false, m1, false))
// (b) Pinning is idempotent, keeps the first pinned_at and is not content.
before := versionOf(m1)
alice.want(http.StatusNoContent, "PUT", pinPath(m1), "")
first := pinnedAt(m1)
alice.want(http.StatusNoContent, "PUT", pinPath(m1), "")
if again := pinnedAt(m1); !again.Equal(first) {
t.Errorf("pinned_at after a second PUT = %v, want the first %v", again, first)
}
if after := versionOf(m1); after.Revision != before.Revision || !after.UpdatedAt.Equal(before.UpdatedAt) {
t.Errorf("material after pins = %+v, want unchanged %+v", after, before)
}
// (c) Pins first, newest pin first, then newest publication.
alice.want(http.StatusNoContent, "PUT", pinPath(m2), "")
if err := gdb.Exec(`UPDATE jz_bm_material_pins SET pinned_at = NOW() - INTERVAL '1 hour' WHERE material_id = ?`, m1).Error; err != nil {
t.Fatal(err)
}
if err := gdb.Exec(`UPDATE jz_bm_material_pins SET pinned_at = NOW() - INTERVAL '2 hours' WHERE material_id = ?`, m2).Error; err != nil {
t.Fatal(err)
}
check("alice with pins", listOf(alice, "?per_page=100"), want(m1, true, m2, true, m3, false))
// (d) Pages are stable and the total counts as before.
for page, wantID := range []uint{m1, m2, m3} {
body := alice.ok("GET", base+"/materials?per_page=1&page="+jsonID(uint(page+1)))
got := rowsOf(list(body))
if len(got) != 1 || got[0].id != jsonID(wantID) {
t.Errorf("page %d = %v, want %d", page+1, got, wantID)
}
if total := obj(body["meta"])["total"]; total != 3.0 {
t.Errorf("page %d total = %v, want 3", page+1, total)
}
}
// (e) Filters apply to pinned rows too.
check("alice type=pdf", listOf(alice, "?type=pdf"), want(m3, false))
check("alice type=notatka", listOf(alice, "?type=notatka"), want(m1, true, m2, true))
// (f) Pins are personal.
check("bob", listOf(bob, "?per_page=100"), want(m3, false, m2, false, m1, false))
if pinned := obj(bob.ok("GET", base+"/materials/"+jsonID(m1))["data"])["pinned"]; pinned != false {
t.Errorf("bob's detail of M1 pinned = %v, want false", pinned)
}
if pinned := obj(alice.ok("GET", base+"/materials/"+jsonID(m1))["data"])["pinned"]; pinned != true {
t.Errorf("alice's detail of M1 pinned = %v, want true", pinned)
}
// (g) The course materials carry the flag and keep newest first.
detail := obj(alice.ok("GET", base+"/courses/"+jsonID(course.ID))["data"])
raw, _ := detail["materials"].([]any)
courseRows := make([]map[string]any, 0, len(raw))
for _, r := range raw {
courseRows = append(courseRows, r.(map[string]any))
}
check("alice course materials", rowsOf(courseRows), want(m3, false, m2, true, m1, true))
// (h) A material the caller cannot see is a 404 either way.
for _, hidden := range []uint{mp, md, 999999} {
alice.want(http.StatusNotFound, "PUT", pinPath(hidden), "")
alice.want(http.StatusNotFound, "DELETE", pinPath(hidden), "")
}
if c := count(t, gdb, "jz_bm_material_pins"); c != 2 {
t.Errorf("pins after the 404s = %d, want 2", c)
}
// (i) Unpinning is idempotent.
alice.want(http.StatusNoContent, "DELETE", pinPath(m2), "")
alice.want(http.StatusNoContent, "DELETE", pinPath(m2), "")
check("alice after unpin", listOf(alice, "?per_page=100"), want(m1, true, m3, false, m2, false))
// (j) The token twin's write scope is pinned by TestTokenMirrorRoutes;
// this package has no personal-token helper, so it is not driven here.
// (k) The pin goes with the material.
if err := gdb.Exec(`DELETE FROM jz_bm_materials WHERE id = ?`, m1).Error; err != nil {
t.Fatal(err)
}
if c := count(t, gdb, "jz_bm_material_pins"); c != 0 {
t.Errorf("pins after deleting M1 = %d, want 0", c)
}
}

View File

@@ -162,7 +162,7 @@ func migratedWith(t *testing.T, env ...string) (*backpack.App, []party.Plugin, *
var bmTables = []string{
"jz_bm_meetings", "jz_bm_courses", "jz_bm_class_sessions", "jz_bm_deadlines", "jz_bm_readings",
"jz_bm_reading_progress", "jz_bm_reading_ownership", "jz_bm_tags", "jz_bm_materials", "jz_bm_material_tag", "jz_bm_calendar_feeds",
"jz_bm_checklists", "jz_bm_checklist_items",
"jz_bm_checklists", "jz_bm_checklist_items", "jz_bm_material_pins",
}
func TestMigrateUpAndRollback(t *testing.T) {