test: cover material pins against postgres
This commit is contained in:
189
material_pins_postgres_test.go
Normal file
189
material_pins_postgres_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user