feat: personal pins on materials
A per-user jz_bm_material_pins table and PUT/DELETE /materials/{id}/pin
on the student API and its token twins: 204 for a material the caller
may see, idempotent, 404 otherwise. List rows, course rows and the
detail carry pinned. The plain list lists the caller's pins first
(newest pin first); with q the relevance order stays. A pin writes only
the pins table and never changes the material's revision.
This commit is contained in:
64
controllers/api/material_pins_test.go
Normal file
64
controllers/api/material_pins_test.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/jakub/sm-bm-plugin/controllers/api"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// pinnedFirstQueries runs GET /materials?course=7&type=notatka as user on a
|
||||
// dry-run database and returns its count and page queries.
|
||||
func pinnedFirstQueries(t *testing.T, user uint) (string, string) {
|
||||
t.Helper()
|
||||
db, rec := dryDB(t)
|
||||
h := &api.Handlers{DB: func() *gorm.DB { return db }}
|
||||
req := httptest.NewRequest(http.MethodGet, "/materials?course=7&type=notatka", nil)
|
||||
req = req.WithContext(bouncer.WithUser(req.Context(), &bouncer.Principal{ID: user}))
|
||||
w := httptest.NewRecorder()
|
||||
h.Materials(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var count, page string
|
||||
for _, q := range rec.queries() {
|
||||
switch {
|
||||
case strings.HasPrefix(q, "SELECT count(*)"):
|
||||
count = q
|
||||
case strings.HasPrefix(q, "SELECT") && strings.Contains(q, `FROM "jz_bm_materials"`):
|
||||
page = q
|
||||
}
|
||||
}
|
||||
if count == "" || page == "" {
|
||||
t.Fatalf("queries = %q, want a count and a page", rec.queries())
|
||||
}
|
||||
return count, page
|
||||
}
|
||||
|
||||
// TestMaterialsPinnedFirstTrace checks the plain list's order: the caller's
|
||||
// own pins first, then newest first with id as the tie-breaker, inside the
|
||||
// same filters, while the count ignores pins.
|
||||
func TestMaterialsPinnedFirstTrace(t *testing.T) {
|
||||
count, page := pinnedFirstQueries(t, 5)
|
||||
for name, q := range map[string]string{"count": count, "page": page} {
|
||||
for _, want := range []string{"jz_bm_materials.course_id = 7", "jz_bm_materials.type = 'notatka'", "jz_bm_materials.is_private = FALSE"} {
|
||||
if !strings.Contains(q, want) {
|
||||
t.Errorf("%s query lacks %q:\n%s", name, want, q)
|
||||
}
|
||||
}
|
||||
}
|
||||
if want := "p.user_id = 5) DESC NULLS LAST, jz_bm_materials.published_at DESC, jz_bm_materials.id DESC"; !strings.Contains(page, want) {
|
||||
t.Errorf("page query is not pinned first for user 5, want %q:\n%s", want, page)
|
||||
}
|
||||
if strings.Contains(count, "jz_bm_material_pins") {
|
||||
t.Errorf("count query reads pins:\n%s", count)
|
||||
}
|
||||
_, other := pinnedFirstQueries(t, 6)
|
||||
if !strings.Contains(other, "p.user_id = 6)") || strings.Contains(other, "p.user_id = 5") {
|
||||
t.Errorf("page query of user 6 does not use only their pins:\n%s", other)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user