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) } }