From bff92cca782dbe390f9f849361b81a27b1de125f Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Mon, 9 Nov 2026 13:15:44 +0100 Subject: [PATCH] test: cover material pins against postgres --- material_pins_postgres_test.go | 189 +++++++++++++++++++++++++++++++++ postgres_test.go | 2 +- 2 files changed, 190 insertions(+), 1 deletion(-) create mode 100644 material_pins_postgres_test.go diff --git a/material_pins_postgres_test.go b/material_pins_postgres_test.go new file mode 100644 index 0000000..ea141a7 --- /dev/null +++ b/material_pins_postgres_test.go @@ -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) + } +} diff --git a/postgres_test.go b/postgres_test.go index a902187..a59c129 100644 --- a/postgres_test.go +++ b/postgres_test.go @@ -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) {