- every revision bump (API writes and /bmadmin saves) enqueues
updated.jz.bm.material with {material_id, revision} in a savepoint
- the materials namespace authorizer allows a subscribe only to a
visible material the user may manage
- api.ManagesMaterials and api.CanWatchMaterial
156 lines
5.1 KiB
Go
156 lines
5.1 KiB
Go
package controllers
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"slices"
|
|
"strconv"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/cabana"
|
|
"git.golem15.com/jakub/sm-bm-plugin/models"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// recordingIndex records the after-commit calls of the materials admin.
|
|
type recordingIndex struct {
|
|
calls []string
|
|
txs []*gorm.DB
|
|
}
|
|
|
|
func (r *recordingIndex) SyncAfterCommit(_ context.Context, tx *gorm.DB, id uint) {
|
|
r.calls = append(r.calls, "sync "+itoa(id))
|
|
r.txs = append(r.txs, tx)
|
|
}
|
|
|
|
func (r *recordingIndex) RemoveAfterCommit(_ context.Context, tx *gorm.DB, id uint) {
|
|
r.calls = append(r.calls, "remove "+itoa(id))
|
|
r.txs = append(r.txs, tx)
|
|
}
|
|
|
|
func (r *recordingIndex) QueueExtract(_ context.Context, tx *gorm.DB, id uint) {
|
|
r.calls = append(r.calls, "queue "+itoa(id))
|
|
r.txs = append(r.txs, tx)
|
|
}
|
|
|
|
func itoa(id uint) string { return strconv.FormatUint(uint64(id), 10) }
|
|
|
|
// recordingNotifier records the live revision events of the materials
|
|
// admin.
|
|
type recordingNotifier struct{ calls []string }
|
|
|
|
func (r *recordingNotifier) Revised(_ context.Context, _ *gorm.DB, id, revision uint) {
|
|
r.calls = append(r.calls, itoa(id)+"@"+itoa(revision))
|
|
}
|
|
|
|
func TestMaterialsAdminHooks(t *testing.T) {
|
|
t.Cleanup(func() { UseMaterialIndex(nil) })
|
|
var c materialsAdmin
|
|
ctx := context.Background()
|
|
m := &models.Material{ID: 7}
|
|
|
|
// No index installed: nothing to do, and no error.
|
|
UseMaterialIndex(nil)
|
|
for name, fn := range map[string]func(context.Context, any) error{"create": c.FormAfterCreate, "update": c.FormAfterUpdate, "delete": c.FormAfterDelete} {
|
|
if err := fn(ctx, m); err != nil {
|
|
t.Errorf("%s without an index: %v", name, err)
|
|
}
|
|
}
|
|
|
|
rec := &recordingIndex{}
|
|
UseMaterialIndex(rec)
|
|
if err := c.FormAfterCreate(ctx, m); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := c.FormAfterUpdate(ctx, m); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := c.FormAfterDelete(ctx, m); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Create and update queue the file text job (in the save transaction)
|
|
// before the after-commit sync; delete queues nothing.
|
|
if want := []string{"queue 7", "sync 7", "queue 7", "sync 7", "remove 7"}; !slices.Equal(rec.calls, want) {
|
|
t.Errorf("calls = %v, want %v", rec.calls, want)
|
|
}
|
|
// Outside a cabana write there is no transaction: the index then runs
|
|
// at once (lagoon.AfterCommit with a nil handle).
|
|
for i, tx := range rec.txs {
|
|
if tx != nil {
|
|
t.Errorf("call %d got a transaction outside a write", i)
|
|
}
|
|
}
|
|
|
|
// A model of another type is an error and schedules nothing.
|
|
rec.calls = nil
|
|
for name, fn := range map[string]func(context.Context, any) error{"create": c.FormAfterCreate, "update": c.FormAfterUpdate, "delete": c.FormAfterDelete} {
|
|
if err := fn(ctx, &models.Course{ID: 7}); err == nil {
|
|
t.Errorf("%s with a course: no error", name)
|
|
}
|
|
}
|
|
var nilMaterial *models.Material
|
|
if err := c.FormAfterUpdate(ctx, nilMaterial); err == nil {
|
|
t.Error("update with a nil material: no error")
|
|
}
|
|
if len(rec.calls) != 0 {
|
|
t.Errorf("calls for a wrong model = %v", rec.calls)
|
|
}
|
|
}
|
|
|
|
// TestMaterialsAdminRevision checks the revision hooks of the materials
|
|
// admin outside a database write: without a transaction they do nothing and
|
|
// store no snapshot, a model that is not a material is an error, and the
|
|
// embedded date check still runs first.
|
|
func TestMaterialsAdminRevision(t *testing.T) {
|
|
t.Cleanup(func() {
|
|
UseMaterialIndex(nil)
|
|
UseMaterialRevisionNotifier(nil)
|
|
})
|
|
UseMaterialIndex(nil)
|
|
notifier := &recordingNotifier{}
|
|
UseMaterialRevisionNotifier(notifier)
|
|
var c materialsAdmin
|
|
ctx := context.Background()
|
|
m := &models.Material{ID: 41, Title: "Notatka", Type: "notatka"}
|
|
|
|
if err := c.FormBeforeUpdate(ctx, m); err != nil {
|
|
t.Errorf("before update without a transaction: %v", err)
|
|
}
|
|
if _, found := pendingRevisions.Load(m.ID); found {
|
|
t.Error("before update without a transaction stored a snapshot")
|
|
}
|
|
if err := c.FormAfterUpdate(ctx, m); err != nil {
|
|
t.Errorf("after update without a transaction: %v", err)
|
|
}
|
|
// Outside a write nothing is revised, so nothing is announced.
|
|
if len(notifier.calls) != 0 {
|
|
t.Errorf("notifier called outside a transaction: %v", notifier.calls)
|
|
}
|
|
// Turning the notifier off is safe.
|
|
UseMaterialRevisionNotifier(nil)
|
|
if n := installedMaterialRevisionNotifier(); n != nil {
|
|
t.Errorf("notifier after UseMaterialRevisionNotifier(nil) = %v, want nil", n)
|
|
}
|
|
if err := c.FormAfterUpdate(ctx, m); err != nil {
|
|
t.Errorf("after update without a notifier: %v", err)
|
|
}
|
|
|
|
if err := c.FormBeforeUpdate(ctx, &models.Course{ID: 41}); err == nil {
|
|
t.Error("before update with a course: no error")
|
|
}
|
|
var nilMaterial *models.Material
|
|
if err := c.FormBeforeUpdate(ctx, nilMaterial); err == nil {
|
|
t.Error("before update with a nil material: no error")
|
|
}
|
|
|
|
at := func(h, m int) time.Time { return time.Date(2026, 10, 3, h, m, 0, 0, models.Location) }
|
|
bad := &models.ClassSession{StartsAt: at(8, 0), EndsAt: at(7, 0)}
|
|
var ve *cabana.ValidationError
|
|
if err := c.FormBeforeUpdate(ctx, bad); !errors.As(err, &ve) {
|
|
t.Errorf("before update with bad dates: err = %v, want a cabana.ValidationError", err)
|
|
} else if _, ok := ve.Details["ends_at"]; !ok {
|
|
t.Errorf("before update with bad dates: details %v name no ends_at", ve.Details)
|
|
}
|
|
}
|