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) } }