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:
Jakub Zych
2026-11-09 13:13:54 +01:00
parent b8b46be398
commit 9436fb62d6
13 changed files with 316 additions and 18 deletions

View File

@@ -77,6 +77,35 @@ func TestMaterialJSONCarriesPrivacy(t *testing.T) {
}
}
// TestMaterialJSONCarriesPinned checks that pinned is always present in a
// material row, false unless the caller set it, and that the detail carries
// the row's value.
func TestMaterialJSONCarriesPinned(t *testing.T) {
row := newMaterialListJSON(models.Material{ID: 1, Title: "Materiał", Type: "notatka"}, 0)
b, err := json.Marshal(row)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(b), `"pinned":false`) {
t.Errorf("material JSON %s does not contain pinned false", b)
}
row.Pinned = true
b, err = json.Marshal(row)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(b), `"pinned":true`) {
t.Errorf("pinned material JSON %s does not contain pinned true", b)
}
b, err = json.Marshal(materialDetailJSON{materialListJSON: row})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(b), `"pinned":true`) {
t.Errorf("pinned material detail JSON %s does not contain pinned true", b)
}
}
// TestVisibleMaterialSQL checks the visibility fragment: public, the author,
// or a share of the caller, with the caller id bound twice.
func TestVisibleMaterialSQL(t *testing.T) {