Files
sm-bm-plugin/admin_test.go
Jakub Zych 71b848a376 Adopt SummerCMS v0.1.1 admin features
Datepickers on native time.Time / lagoon.Date fields, file uploads on
materials, readings and class sessions managed from the course form
(course_id made nullable).
2026-10-03 06:03:34 +02:00

247 lines
10 KiB
Go

package bm_test
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/surf"
"git.golem15.com/jakub/sm-bm-plugin/models"
"gorm.io/gorm"
)
const adminAPI = "/backend/api/v1"
// adminToken creates an administrator whose role grants perms and signs in
// through the admin API.
func adminToken(t *testing.T, gdb *gorm.DB, h http.Handler, perms string) string {
t.Helper()
hash, err := bouncer.HashPassword(10, "admin-secret-password")
if err != nil {
t.Fatal(err)
}
var roleID uint
if err := gdb.Raw(`INSERT INTO backend_user_roles (name, code, permissions, is_system, created_at, updated_at)
VALUES ('BM', 'bm', ?, FALSE, NOW(), NOW()) RETURNING id`, perms).Scan(&roleID).Error; err != nil {
t.Fatal(err)
}
if err := gdb.Exec(`INSERT INTO backend_users (login, email, password, is_activated, is_superuser, role_id, created_at, updated_at)
VALUES ('editor', 'editor@example.test', ?, TRUE, FALSE, ?, NOW(), NOW())`, hash, roleID).Error; err != nil {
t.Fatal(err)
}
rec := adminSend(t, h, http.MethodPost, "/auth/login", "", map[string]string{"login": "editor", "password": "admin-secret-password"})
var body struct {
Data struct {
AccessToken string `json:"access_token"`
} `json:"data"`
}
if rec.Code != http.StatusOK || json.Unmarshal(rec.Body.Bytes(), &body) != nil || body.Data.AccessToken == "" {
t.Fatalf("admin login = %d %s", rec.Code, rec.Body.String())
}
return body.Data.AccessToken
}
func adminSend(t *testing.T, h http.Handler, method, path, token string, body any) *httptest.ResponseRecorder {
t.Helper()
var reader io.Reader
if body != nil {
raw, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
reader = bytes.NewReader(raw)
}
req := httptest.NewRequest(method, adminAPI+path, reader)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
req.Header.Set("Accept-Language", "en")
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
func createdID(t *testing.T, rec *httptest.ResponseRecorder) uint {
t.Helper()
if rec.Code != http.StatusCreated {
t.Fatalf("create = %d %s", rec.Code, rec.Body.String())
}
var body struct {
Data struct {
ID uint `json:"id"`
} `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body.Data.ID == 0 {
t.Fatalf("create body %s: %v", rec.Body.String(), err)
}
return body.Data.ID
}
func TestAdminCRUDAgainstDatabase(t *testing.T) {
app, plugins, gdb := migrated(t)
h, err := surf.Assemble(app, plugins)
if err != nil {
t.Fatal(err)
}
token := adminToken(t, gdb, h, `{"jz.bm.access_courses":1,"jz.bm.access_schedule":1,"jz.bm.access_materials":1}`)
courseID := createdID(t, adminSend(t, h, http.MethodPost, "/jz/bm/courses", token, map[string]any{
"name": "Przedmiot testowy", "form": "seminarium", "semester": 2, "ects": 5, "elective_group": "", "lecturer": "dr X",
}))
var course models.Course
gdb.Take(&course, courseID)
if course.ElectiveGroup != nil || course.Lecturer == nil || course.ECTS == nil || *course.ECTS != 5 {
t.Errorf("course = %+v", course)
}
if rec := adminSend(t, h, http.MethodPost, "/jz/bm/courses", token, map[string]any{"name": "X", "form": "lecture", "semester": 1}); rec.Code != http.StatusUnprocessableEntity {
t.Errorf("bad form = %d %s", rec.Code, rec.Body.String())
}
meetingID := createdID(t, adminSend(t, h, http.MethodPost, "/jz/bm/meetings", token, map[string]any{
"number": 1, "starts_on": "2030-10-05", "ends_on": "2030-10-06",
}))
if rec := adminSend(t, h, http.MethodPost, "/jz/bm/meetings", token, map[string]any{"number": 2, "starts_on": "5.10.2030", "ends_on": "2030-10-06"}); rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "starts_on") {
t.Errorf("bad date = %d %s", rec.Code, rec.Body.String())
}
sessionID := createdID(t, adminSend(t, h, http.MethodPost, "/jz/bm/class_sessions", token, map[string]any{
"course": courseID, "meeting": meetingID, "starts_at": "2030-10-05T06:00:00Z", "ends_at": "2030-10-05T07:30:00Z", "room": "", "is_online": true,
}))
var stored time.Time
gdb.Raw(`SELECT starts_at FROM jz_bm_class_sessions WHERE id = ?`, sessionID).Scan(&stored)
if !stored.Equal(time.Date(2030, 10, 5, 6, 0, 0, 0, time.UTC)) {
t.Errorf("stored starts_at = %v, want 06:00 UTC", stored)
}
var session models.ClassSession
gdb.Take(&session, sessionID)
if session.CourseID == nil || *session.CourseID != courseID || session.MeetingID == nil || *session.MeetingID != meetingID || session.Room != nil || !session.IsOnline {
t.Errorf("session = %+v", session)
}
rec := adminSend(t, h, http.MethodPost, "/jz/bm/class_sessions", token, map[string]any{
"course": courseID, "starts_at": "2030-10-05T08:00:00Z", "ends_at": "2030-10-05T07:00:00Z",
})
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "ends_at") {
t.Errorf("bad ends_at = %d %s", rec.Code, rec.Body.String())
}
if rec := adminSend(t, h, http.MethodGet, "/jz/bm/class_sessions", token, nil); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Przedmiot testowy") {
t.Errorf("session list = %d %s", rec.Code, rec.Body.String())
}
if rec := adminSend(t, h, http.MethodGet, "/jz/bm/class_sessions/"+strconv.Itoa(int(sessionID)), token, nil); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "2030-10-05T08:00:00+02:00") {
t.Errorf("session show = %d %s", rec.Code, rec.Body.String())
}
tagID := createdID(t, adminSend(t, h, http.MethodPost, "/jz/bm/tags", token, map[string]any{"name": "Geopolityka", "slug": "geopolityka"}))
materialID := createdID(t, adminSend(t, h, http.MethodPost, "/jz/bm/materials", token, map[string]any{
"title": "Notatka", "type": "notatka", "course": courseID, "tags": []uint{tagID}, "published_at": nil, "body": "# Treść",
}))
var material models.Material
gdb.Preload("Tags").Take(&material, materialID)
if material.PublishedAt != nil || len(material.Tags) != 1 || material.CourseID == nil {
t.Errorf("material = %+v", material)
}
rec = adminSend(t, h, http.MethodPut, "/jz/bm/materials/"+strconv.Itoa(int(materialID)), token, map[string]any{
"title": "Notatka", "type": "notatka", "published_at": "2026-01-15T11:00:00Z",
})
if rec.Code != http.StatusOK {
t.Fatalf("publish = %d %s", rec.Code, rec.Body.String())
}
gdb.Take(&material, materialID)
if material.PublishedAt == nil || !material.PublishedAt.Equal(time.Date(2026, 1, 15, 11, 0, 0, 0, time.UTC)) {
t.Errorf("published_at = %v", material.PublishedAt)
}
// Permissions are per screen group.
gdb.Exec(`UPDATE backend_user_roles SET permissions = '{"jz.bm.access_materials":1}'`)
limited := adminToken2(t, gdb, h)
if rec := adminSend(t, h, http.MethodGet, "/jz/bm/courses", limited, nil); rec.Code != http.StatusForbidden {
t.Errorf("courses without access_courses = %d", rec.Code)
}
if rec := adminSend(t, h, http.MethodGet, "/jz/bm/materials", limited, nil); rec.Code != http.StatusOK {
t.Errorf("materials with access_materials = %d %s", rec.Code, rec.Body.String())
}
}
// adminToken2 signs the existing editor in again, after a role change.
func adminToken2(t *testing.T, gdb *gorm.DB, h http.Handler) string {
t.Helper()
rec := adminSend(t, h, http.MethodPost, "/auth/login", "", map[string]string{"login": "editor", "password": "admin-secret-password"})
var body struct {
Data struct {
AccessToken string `json:"access_token"`
} `json:"data"`
}
if rec.Code != http.StatusOK || json.Unmarshal(rec.Body.Bytes(), &body) != nil {
t.Fatalf("admin login = %d %s", rec.Code, rec.Body.String())
}
return body.Data.AccessToken
}
// TestCourseRelationManagersOnUnsavedCourse creates a reading and a class
// session from the course form before the course exists: they wait without
// a course, stay out of the student API, and get the course on its first
// save with the same form session key.
func TestCourseRelationManagersOnUnsavedCourse(t *testing.T) {
app, plugins, gdb := migrated(t)
h, err := surf.Assemble(app, plugins)
if err != nil {
t.Fatal(err)
}
token := adminToken(t, gdb, h, `{"jz.bm.access_courses":1}`)
key := strings.Repeat("k", 40)
send := func(method, path string, body any) *httptest.ResponseRecorder {
t.Helper()
raw, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(method, adminAPI+path, bytes.NewReader(raw))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Accept-Language", "en")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Session-Key", key)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
readingID := createdID(t, send(http.MethodPost, "/jz/bm/courses/0/relations/readings/records", map[string]any{
"title": "Lektura w toku", "is_required": true,
}))
sessionID := createdID(t, send(http.MethodPost, "/jz/bm/courses/0/relations/sessions/records", map[string]any{
"starts_at": "2030-10-05T06:00:00Z", "ends_at": "2030-10-05T07:30:00Z", "room": "237",
}))
if rec := send(http.MethodPost, "/jz/bm/courses/0/relations/sessions/records", map[string]any{
"starts_at": "2030-10-05T08:00:00Z", "ends_at": "2030-10-05T07:00:00Z",
}); rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "ends_at") {
t.Errorf("session ending before it starts = %d %s", rec.Code, rec.Body.String())
}
var pending models.ClassSession
gdb.Take(&pending, sessionID)
if pending.CourseID != nil {
t.Fatalf("pending session has course %d", *pending.CourseID)
}
courseID := createdID(t, send(http.MethodPost, "/jz/bm/courses", map[string]any{
"name": "Przedmiot z lekturami", "form": "wyklad", "semester": 1,
}))
var reading models.Reading
gdb.Take(&reading, readingID)
var session models.ClassSession
gdb.Take(&session, sessionID)
if reading.CourseID == nil || *reading.CourseID != courseID || session.CourseID == nil || *session.CourseID != courseID {
t.Errorf("after save: reading course %v, session course %v, want %d", reading.CourseID, session.CourseID, courseID)
}
if !session.StartsAt.Equal(time.Date(2030, 10, 5, 6, 0, 0, 0, time.UTC)) {
t.Errorf("session starts_at = %v", session.StartsAt)
}
}