190 lines
7.0 KiB
Go
190 lines
7.0 KiB
Go
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)
|
|
}
|
|
}
|