Testcontainers Postgres, a fresh database per test, skipped under -short: migrations up and down, schema constraints, bm:seed idempotency and rollback, every API route through the real jwt.auth guard, and admin saves through the admin API.
186 lines
7.6 KiB
Go
186 lines
7.6 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-05 08:00", "ends_at": "2030-10-05 09:30", "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 != 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-05 10:00", "ends_at": "wtorek",
|
|
})
|
|
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": "", "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-15 12:00",
|
|
})
|
|
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 != "2026-01-15T12:00:00+01:00" {
|
|
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
|
|
}
|