test: add Postgres integration tests
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.
This commit is contained in:
185
admin_test.go
Normal file
185
admin_test.go
Normal file
@@ -0,0 +1,185 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user