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
|
||||
}
|
||||
661
postgres_test.go
Normal file
661
postgres_test.go
Normal file
@@ -0,0 +1,661 @@
|
||||
package bm_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
"git.golem15.com/golem15/summercms/modules/surf"
|
||||
"git.golem15.com/jakub/sm-bm-plugin/models"
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
"github.com/testcontainers/testcontainers-go"
|
||||
"github.com/testcontainers/testcontainers-go/modules/postgres"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// The database tests run against a throwaway testcontainers Postgres, never
|
||||
// a developer database. Under -short the container is not started and the
|
||||
// tests skip; in a full run a missing Docker daemon fails the package.
|
||||
var (
|
||||
pgContainer *postgres.PostgresContainer
|
||||
pgAdmin *sql.DB
|
||||
pgDSN string
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
if !isShort() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
err := startPostgres(ctx)
|
||||
cancel()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "jz.bm: testcontainers postgres: %v\n", err)
|
||||
stopPostgres()
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
code := m.Run()
|
||||
stopPostgres()
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
func isShort() bool {
|
||||
for _, a := range os.Args {
|
||||
if a == "-test.short" || a == "-test.short=true" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func startPostgres(ctx context.Context) error {
|
||||
ctr, err := postgres.Run(ctx,
|
||||
"postgres:16-alpine",
|
||||
postgres.WithDatabase("bm"),
|
||||
postgres.WithUsername("bm"),
|
||||
postgres.WithPassword("bm"),
|
||||
postgres.BasicWaitStrategies(),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pgContainer = ctr
|
||||
dsn, err := ctr.ConnectionString(ctx, "sslmode=disable")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
_ = db.Close()
|
||||
return err
|
||||
}
|
||||
pgAdmin, pgDSN = db, dsn
|
||||
return nil
|
||||
}
|
||||
|
||||
func stopPostgres() {
|
||||
if pgAdmin != nil {
|
||||
_ = pgAdmin.Close()
|
||||
}
|
||||
if pgContainer != nil {
|
||||
_ = testcontainers.TerminateContainer(pgContainer)
|
||||
}
|
||||
}
|
||||
|
||||
// testDatabase creates a database for one test and drops it afterwards.
|
||||
func testDatabase(t *testing.T) (*sql.DB, string) {
|
||||
t.Helper()
|
||||
if testing.Short() {
|
||||
t.Skip("requires testcontainers postgres")
|
||||
}
|
||||
if pgAdmin == nil {
|
||||
t.Fatal("postgres unavailable: the container was not started")
|
||||
}
|
||||
name := "bm_" + strings.ToLower(strings.NewReplacer("/", "_", "-", "_").Replace(t.Name()))
|
||||
quoted := `"` + strings.ReplaceAll(name, `"`, `""`) + `"`
|
||||
if _, err := pgAdmin.ExecContext(t.Context(), `CREATE DATABASE `+quoted+` TEMPLATE template0 ENCODING 'UTF8'`); err != nil {
|
||||
t.Fatalf("create %s: %v", name, err)
|
||||
}
|
||||
u, err := url.Parse(pgDSN)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
u.Path = "/" + name
|
||||
dsn := u.String()
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = db.Close()
|
||||
_, _ = pgAdmin.ExecContext(context.Background(), `DROP DATABASE IF EXISTS `+quoted+` WITH (FORCE)`)
|
||||
})
|
||||
return db, dsn
|
||||
}
|
||||
|
||||
// migrated activates golem15.user and jz.bm, migrates a fresh database and
|
||||
// publishes it on the application, as serve does.
|
||||
func migrated(t *testing.T) (*backpack.App, []party.Plugin, *gorm.DB) {
|
||||
t.Helper()
|
||||
sqlDB, _ := testDatabase(t)
|
||||
gdb, err := lagoon.Use(t.Context(), sqlDB)
|
||||
if err != nil {
|
||||
t.Fatalf("lagoon.Use: %v", err)
|
||||
}
|
||||
app := backpack.New(testConfig(t))
|
||||
plugins := activate(t, app)
|
||||
if err := lagoon.Migrate(gdb, plugins); err != nil {
|
||||
t.Fatalf("lagoon.Migrate: %v", err)
|
||||
}
|
||||
if err := lagoon.Publish(app, sqlDB, gdb); err != nil {
|
||||
t.Fatalf("lagoon.Publish: %v", err)
|
||||
}
|
||||
return app, plugins, gdb
|
||||
}
|
||||
|
||||
var bmTables = []string{
|
||||
"jz_bm_meetings", "jz_bm_courses", "jz_bm_class_sessions", "jz_bm_deadlines", "jz_bm_readings",
|
||||
"jz_bm_reading_progress", "jz_bm_tags", "jz_bm_materials", "jz_bm_material_tag",
|
||||
}
|
||||
|
||||
func TestMigrateUpAndRollback(t *testing.T) {
|
||||
_, plugins, gdb := migrated(t)
|
||||
m := gdb.Migrator()
|
||||
for _, table := range bmTables {
|
||||
if !m.HasTable(table) {
|
||||
t.Errorf("%s is missing after migrate", table)
|
||||
}
|
||||
}
|
||||
if !m.HasColumn(&models.Course{}, "elective_group") {
|
||||
t.Error("jz_bm_courses has no elective_group")
|
||||
}
|
||||
n := len(bmPlugin(t, plugins).Migrations())
|
||||
for i := 0; i < n; i++ {
|
||||
if err := lagoon.RollbackLast(gdb, plugins, "jz.bm"); err != nil {
|
||||
t.Fatalf("rollback %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
for _, table := range bmTables {
|
||||
if m.HasTable(table) {
|
||||
t.Errorf("%s is still there after rolling every migration back", table)
|
||||
}
|
||||
}
|
||||
if !m.HasTable("users") {
|
||||
t.Error("rolling back jz.bm dropped golem15.user's users table")
|
||||
}
|
||||
if err := lagoon.Migrate(gdb, plugins); err != nil {
|
||||
t.Fatalf("migrate again: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaConstraints(t *testing.T) {
|
||||
_, _, gdb := migrated(t)
|
||||
exec := func(sql string, args ...any) error { return gdb.Exec(sql, args...).Error }
|
||||
if err := exec(`INSERT INTO jz_bm_courses (name, form, semester) VALUES ('A', 'wyklad', 1)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, bad := range []string{
|
||||
`INSERT INTO jz_bm_courses (name, form, semester) VALUES ('A', 'wyklad', 1)`,
|
||||
`INSERT INTO jz_bm_courses (name, form, semester) VALUES ('B', 'lecture', 1)`,
|
||||
`INSERT INTO jz_bm_meetings (number, starts_on, ends_on) VALUES (1, '2030-10-06', '2030-10-05')`,
|
||||
`INSERT INTO jz_bm_class_sessions (course_id, starts_at, ends_at) VALUES (999, NOW(), NOW() + interval '1 hour')`,
|
||||
`INSERT INTO jz_bm_deadlines (title, due_at, kind) VALUES ('X', NOW(), 'party')`,
|
||||
`INSERT INTO jz_bm_materials (title, type) VALUES ('X', 'video')`,
|
||||
} {
|
||||
if err := exec(bad); err == nil {
|
||||
t.Errorf("accepted: %s", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func seedFixture(t *testing.T, plugins []party.Plugin, path string) string {
|
||||
t.Helper()
|
||||
var out bytes.Buffer
|
||||
if err := bonfire.Call(t.Context(), bmPlugin(t, plugins).Commands(), "bm:seed", []string{path}, &out); err != nil {
|
||||
t.Fatalf("bm:seed %s: %v\n%s", path, err, out.String())
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func count(t *testing.T, gdb *gorm.DB, table string) int64 {
|
||||
t.Helper()
|
||||
var n int64
|
||||
if err := gdb.Table(table).Count(&n).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func TestSeedCommandIsIdempotent(t *testing.T) {
|
||||
_, plugins, gdb := migrated(t)
|
||||
out := seedFixture(t, plugins, "testdata/seed.example.json")
|
||||
if !strings.Contains(out, "courses") {
|
||||
t.Errorf("summary = %q", out)
|
||||
}
|
||||
want := map[string]int64{"jz_bm_meetings": 2, "jz_bm_courses": 3, "jz_bm_class_sessions": 3, "jz_bm_deadlines": 2, "jz_bm_readings": 2}
|
||||
for table, n := range want {
|
||||
if got := count(t, gdb, table); got != n {
|
||||
t.Errorf("%s = %d rows, want %d", table, got, n)
|
||||
}
|
||||
}
|
||||
var session models.ClassSession
|
||||
if err := gdb.Preload("Course").Preload("Meeting").Where("room = ?", "101").Take(&session).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if session.StartsAt != "2030-10-05T08:00:00+02:00" || session.Course.Name != "Przedmiot przykładowy A" || session.Meeting.Number != 1 {
|
||||
t.Errorf("session = %+v", session)
|
||||
}
|
||||
var stored time.Time
|
||||
if err := gdb.Raw(`SELECT starts_at FROM jz_bm_class_sessions WHERE room = '101'`).Scan(&stored).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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 course models.Course
|
||||
if err := gdb.Where("form = ?", "konwersatorium").Take(&course).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if course.ElectiveGroup == nil || *course.ElectiveGroup != "przyklad-do-wyboru" || course.Code != nil {
|
||||
t.Errorf("course = %+v", course)
|
||||
}
|
||||
var ids []uint
|
||||
gdb.Model(&models.Course{}).Order("id").Pluck("id", &ids)
|
||||
|
||||
// A second run changes nothing and keeps the IDs.
|
||||
out = seedFixture(t, plugins, "testdata/seed.example.json")
|
||||
for _, row := range []string{"meetings\t0\t0\t2", "courses\t0\t0\t3", "class_sessions\t0\t0\t3", "deadlines\t0\t0\t2", "readings\t0\t0\t2"} {
|
||||
if !strings.Contains(out, row) {
|
||||
t.Errorf("second run summary lacks %q:\n%s", row, out)
|
||||
}
|
||||
}
|
||||
for table, n := range want {
|
||||
if got := count(t, gdb, table); got != n {
|
||||
t.Errorf("after the second run %s = %d rows, want %d", table, got, n)
|
||||
}
|
||||
}
|
||||
var again []uint
|
||||
gdb.Model(&models.Course{}).Order("id").Pluck("id", &again)
|
||||
if fmt.Sprint(ids) != fmt.Sprint(again) {
|
||||
t.Errorf("course IDs changed: %v -> %v", ids, again)
|
||||
}
|
||||
|
||||
// A changed field updates the row in place.
|
||||
dir := t.TempDir()
|
||||
changed := filepath.Join(dir, "changed.json")
|
||||
writeJSON(t, changed, `{"courses": [{"key": "a", "name": "Przedmiot przykładowy A", "form": "wyklad", "semester": 1, "lecturer": "dr Inna Osoba", "sort_order": 1}],
|
||||
"class_sessions": [{"course": "a", "starts_at": "2030-10-05T08:00:00+02:00", "ends_at": "2030-10-05T10:00:00+02:00", "room": "105"}]}`)
|
||||
seedFixture(t, plugins, changed)
|
||||
var updated models.Course
|
||||
gdb.Where("id = ?", ids[0]).Take(&updated)
|
||||
if updated.Lecturer == nil || *updated.Lecturer != "dr Inna Osoba" || count(t, gdb, "jz_bm_courses") != 3 {
|
||||
t.Errorf("course after update = %+v", updated)
|
||||
}
|
||||
gdb.Where("course_id = ?", ids[0]).Take(&session)
|
||||
if session.Room == nil || *session.Room != "105" || session.MeetingID != nil || count(t, gdb, "jz_bm_class_sessions") != 3 {
|
||||
t.Errorf("session after update = %+v", session)
|
||||
}
|
||||
|
||||
// An unknown key fails the whole file: the valid meeting is not written.
|
||||
invalid := filepath.Join(dir, "invalid.json")
|
||||
writeJSON(t, invalid, `{"meetings": [{"number": 3, "starts_on": "2030-11-01", "ends_on": "2030-11-02"}],
|
||||
"readings": [{"course": "ghost", "title": "X"}]}`)
|
||||
var buf bytes.Buffer
|
||||
err := bonfire.Call(t.Context(), bmPlugin(t, plugins).Commands(), "bm:seed", []string{invalid}, &buf)
|
||||
if err == nil || !strings.Contains(err.Error(), "ghost") {
|
||||
t.Errorf("invalid seed err = %v", err)
|
||||
}
|
||||
if got := count(t, gdb, "jz_bm_meetings"); got != 2 {
|
||||
t.Errorf("an invalid file wrote meetings: %d rows", got)
|
||||
}
|
||||
|
||||
// A database error inside the transaction rolls back the rows before it.
|
||||
clash := filepath.Join(dir, "clash.json")
|
||||
writeJSON(t, clash, `{"meetings": [{"number": 4, "starts_on": "2030-12-01", "ends_on": "2030-12-02"}],
|
||||
"courses": [{"key": "x", "name": "`+strings.Repeat("x", 300)+`", "form": "inne", "semester": 2}]}`)
|
||||
if err := bonfire.Call(t.Context(), bmPlugin(t, plugins).Commands(), "bm:seed", []string{clash}, &buf); err == nil {
|
||||
t.Error("a name longer than the column was accepted")
|
||||
}
|
||||
if got := count(t, gdb, "jz_bm_meetings"); got != 2 {
|
||||
t.Errorf("a failed seed kept its meetings: %d rows", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeedCommandOpensDatabase runs bm:seed the way the binary does:
|
||||
// nothing is published on the app, so the command opens database.dsn.
|
||||
func TestSeedCommandOpensDatabase(t *testing.T) {
|
||||
_, _, gdb := migrated(t)
|
||||
var name string
|
||||
if err := gdb.Raw("SELECT current_database()").Scan(&name).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
u, err := url.Parse(pgDSN)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
u.Path = "/" + name
|
||||
app := backpack.New(testConfig(t))
|
||||
plugins := activate(t, app)
|
||||
if err := app.Config.Set("database.dsn", u.String()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := app.Config.Set("app.key", base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{7}, 32))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seedFixture(t, plugins, "testdata/seed.example.json")
|
||||
if got := count(t, gdb, "jz_bm_courses"); got != 3 {
|
||||
t.Errorf("courses = %d, want 3", got)
|
||||
}
|
||||
}
|
||||
|
||||
func writeJSON(t *testing.T, path, body string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// apiClient drives the assembled application as a signed-in user.
|
||||
type apiClient struct {
|
||||
t *testing.T
|
||||
h http.Handler
|
||||
token string
|
||||
}
|
||||
|
||||
func (c apiClient) do(method, path string) (int, map[string]any) {
|
||||
c.t.Helper()
|
||||
req := httptest.NewRequest(method, path, nil)
|
||||
if c.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
c.h.ServeHTTP(rec, req)
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
c.t.Fatalf("%s %s = %d, not JSON: %s", method, path, rec.Code, rec.Body.String())
|
||||
}
|
||||
return rec.Code, body
|
||||
}
|
||||
|
||||
func (c apiClient) ok(method, path string) map[string]any {
|
||||
c.t.Helper()
|
||||
code, body := c.do(method, path)
|
||||
if code != http.StatusOK {
|
||||
c.t.Fatalf("%s %s = %d %v, want 200", method, path, code, body)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func createUser(t *testing.T, gdb *gorm.DB, email string) uint {
|
||||
t.Helper()
|
||||
var id uint
|
||||
if err := gdb.Raw(`INSERT INTO users (email) VALUES (?) RETURNING id`, email).Scan(&id).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func tokenFor(t *testing.T, id uint) string {
|
||||
t.Helper()
|
||||
token, _, err := bouncer.Mint(testUserSecret, strconv.FormatUint(uint64(id), 10), "http://127.0.0.1/api/login", time.Hour)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
func list(body map[string]any) []map[string]any {
|
||||
raw, _ := body["data"].([]any)
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
out = append(out, item.(map[string]any))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func obj(v any) map[string]any {
|
||||
m, _ := v.(map[string]any)
|
||||
return m
|
||||
}
|
||||
|
||||
func id(v any) string { return strconv.Itoa(int(v.(float64))) }
|
||||
|
||||
func TestAPIAgainstDatabase(t *testing.T) {
|
||||
app, plugins, gdb := migrated(t)
|
||||
seedFixture(t, plugins, "testdata/seed.example.json")
|
||||
h, err := surf.Assemble(app, plugins)
|
||||
if err != nil {
|
||||
t.Fatalf("Assemble: %v", err)
|
||||
}
|
||||
alice := apiClient{t: t, h: h, token: tokenFor(t, createUser(t, gdb, "alice@example.test"))}
|
||||
bob := apiClient{t: t, h: h, token: tokenFor(t, createUser(t, gdb, "bob@example.test"))}
|
||||
guest := apiClient{t: t, h: h}
|
||||
|
||||
if code, body := guest.do("GET", "/_bm/api/v1/dashboard"); code != http.StatusUnauthorized {
|
||||
t.Errorf("guest dashboard = %d %v, want 401", code, body)
|
||||
}
|
||||
|
||||
var courseA, courseAC models.Course
|
||||
gdb.Where("form = ?", "wyklad").Take(&courseA)
|
||||
gdb.Where("form = ?", "cwiczenia").Take(&courseAC)
|
||||
|
||||
// Materials: a published note with tags and files, a published link, a
|
||||
// draft and one scheduled for the future.
|
||||
past := models.DateTime("2026-01-15 12:00")
|
||||
future := models.DateTime("2099-01-01 12:00")
|
||||
note := models.Material{CourseID: &courseA.ID, Title: "Notatka", Type: "notatka", Body: ptr("# Nagłówek"), PublishedAt: &past}
|
||||
link := models.Material{CourseID: &courseAC.ID, Title: "Link", Type: "link", URL: ptr("https://example.test"), PublishedAt: &past}
|
||||
draft := models.Material{CourseID: &courseA.ID, Title: "Szkic", Type: "notatka"}
|
||||
later := models.Material{CourseID: &courseA.ID, Title: "Później", Type: "pdf", PublishedAt: &future}
|
||||
for _, m := range []*models.Material{¬e, &link, &draft, &later} {
|
||||
if err := gdb.Create(m).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
geo := models.Tag{Name: "Geopolityka", Slug: "geopolityka"}
|
||||
alpha := models.Tag{Name: "Alfa", Slug: "alfa"}
|
||||
gdb.Create(&geo)
|
||||
gdb.Create(&alpha)
|
||||
gdb.Create(&[]models.MaterialTag{{MaterialID: note.ID, TagID: geo.ID}, {MaterialID: note.ID, TagID: alpha.ID}, {MaterialID: draft.ID, TagID: geo.ID}})
|
||||
private := false
|
||||
for i, f := range []attach.File{
|
||||
{DiskName: "aaaaaaaaaaaaaaaaaaaaaaaa.pdf", FileName: "wyklad.pdf", FileSize: 1234, ContentType: "application/pdf"},
|
||||
{DiskName: "bbbbbbbbbbbbbbbbbbbbbbbb.pdf", FileName: "prywatny.pdf", FileSize: 99, ContentType: "application/pdf", IsPublic: &private},
|
||||
} {
|
||||
f.Field = models.MaterialFilesField
|
||||
f.AttachmentType = models.Material{}.MorphName()
|
||||
f.AttachmentID = strconv.FormatUint(uint64(note.ID), 10)
|
||||
f.SortOrder = i
|
||||
if err := gdb.Create(&f).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Dashboard: the fixture lies in 2030, so everything is upcoming.
|
||||
dash := obj(alice.ok("GET", "/_bm/api/v1/dashboard")["data"])
|
||||
sessions, _ := dash["next_sessions"].([]any)
|
||||
if len(sessions) != 3 {
|
||||
t.Fatalf("next_sessions = %v", dash["next_sessions"])
|
||||
}
|
||||
first := obj(sessions[0])
|
||||
if first["starts_at"] != "2030-10-05T08:00:00+02:00" || first["ends_at"] != "2030-10-05T09:30:00+02:00" ||
|
||||
obj(first["course"])["name"] != "Przedmiot przykładowy A" || first["meeting_number"] != float64(1) || first["room"] != "101" {
|
||||
t.Errorf("first session = %v", first)
|
||||
}
|
||||
if deadlines, _ := dash["upcoming_deadlines"].([]any); len(deadlines) != 2 || obj(deadlines[1])["due_at"] != "2030-11-30T23:59:00+01:00" {
|
||||
t.Errorf("upcoming_deadlines = %v", dash["upcoming_deadlines"])
|
||||
}
|
||||
if dash["current_meeting"] != nil || obj(dash["next_meeting"])["number"] != float64(1) || obj(dash["next_meeting"])["starts_on"] != "2030-10-05" {
|
||||
t.Errorf("meetings = %v / %v", dash["current_meeting"], dash["next_meeting"])
|
||||
}
|
||||
if r := obj(dash["readings"]); r["total"] != float64(2) || r["read"] != float64(0) || r["required_total"] != float64(1) || r["required_read"] != float64(0) {
|
||||
t.Errorf("readings = %v", r)
|
||||
}
|
||||
|
||||
// Reading progress is per user and idempotent.
|
||||
var required models.Reading
|
||||
gdb.Where("is_required").Take(&required)
|
||||
rid := strconv.FormatUint(uint64(required.ID), 10)
|
||||
marked := obj(alice.ok("PUT", "/_bm/api/v1/readings/"+rid+"/progress")["data"])
|
||||
readAt, _ := marked["read_at"].(string)
|
||||
if marked["reading_id"] != float64(required.ID) || !strings.HasSuffix(readAt, "+01:00") && !strings.HasSuffix(readAt, "+02:00") {
|
||||
t.Errorf("mark = %v", marked)
|
||||
}
|
||||
if again := obj(alice.ok("PUT", "/_bm/api/v1/readings/"+rid+"/progress")["data"]); again["read_at"] != readAt {
|
||||
t.Errorf("marking again changed read_at: %v -> %v", readAt, again["read_at"])
|
||||
}
|
||||
if r := obj(obj(alice.ok("GET", "/_bm/api/v1/dashboard")["data"])["readings"]); r["read"] != float64(1) || r["required_read"] != float64(1) {
|
||||
t.Errorf("alice readings = %v", r)
|
||||
}
|
||||
if r := obj(obj(bob.ok("GET", "/_bm/api/v1/dashboard")["data"])["readings"]); r["read"] != float64(0) {
|
||||
t.Errorf("bob sees alice's progress: %v", r)
|
||||
}
|
||||
for _, path := range []string{"/_bm/api/v1/readings/99999/progress"} {
|
||||
if code, _ := alice.do("PUT", path); code != http.StatusNotFound {
|
||||
t.Errorf("PUT %s = %d, want 404", path, code)
|
||||
}
|
||||
if code, _ := alice.do("DELETE", path); code != http.StatusNotFound {
|
||||
t.Errorf("DELETE %s = %d, want 404", path, code)
|
||||
}
|
||||
}
|
||||
|
||||
// Readings list with the caller's state.
|
||||
readings := list(alice.ok("GET", "/_bm/api/v1/readings?course="+strconv.FormatUint(uint64(courseA.ID), 10)))
|
||||
if len(readings) != 2 || readings[0]["title"] != "Lektura przykładowa 1" || readings[0]["read_at"] != readAt || readings[1]["read_at"] != nil {
|
||||
t.Errorf("alice readings = %v", readings)
|
||||
}
|
||||
if obj(readings[0]["course"])["form"] != "wyklad" || readings[0]["author"] != "A. Autor" || readings[0]["is_required"] != true {
|
||||
t.Errorf("reading = %v", readings[0])
|
||||
}
|
||||
if bobs := list(bob.ok("GET", "/_bm/api/v1/readings")); len(bobs) != 2 || bobs[0]["read_at"] != nil {
|
||||
t.Errorf("bob readings = %v", bobs)
|
||||
}
|
||||
if none := list(alice.ok("GET", "/_bm/api/v1/readings?course=99999")); len(none) != 0 {
|
||||
t.Errorf("unknown course readings = %v", none)
|
||||
}
|
||||
|
||||
// Courses with counters.
|
||||
courses := list(alice.ok("GET", "/_bm/api/v1/courses"))
|
||||
if len(courses) != 3 || courses[0]["name"] != "Przedmiot przykładowy A" || courses[0]["form"] != "wyklad" {
|
||||
t.Fatalf("courses = %v", courses)
|
||||
}
|
||||
if c := obj(courses[0]["counts"]); c["sessions"] != float64(1) || c["readings"] != float64(2) || c["readings_read"] != float64(1) || c["materials"] != float64(1) {
|
||||
t.Errorf("course A counts = %v", c)
|
||||
}
|
||||
if courses[0]["ects"] != float64(4) || courses[1]["ects"] != 2.5 || courses[2]["elective_group"] != "przyklad-do-wyboru" || courses[0]["elective_group"] != nil {
|
||||
t.Errorf("course fields = %v", courses)
|
||||
}
|
||||
if two := list(alice.ok("GET", "/_bm/api/v1/courses?semester=2")); len(two) != 0 {
|
||||
t.Errorf("semester 2 = %v", two)
|
||||
}
|
||||
detail := obj(alice.ok("GET", "/_bm/api/v1/courses/"+strconv.FormatUint(uint64(courseA.ID), 10))["data"])
|
||||
if s, _ := detail["sessions"].([]any); len(s) != 1 {
|
||||
t.Errorf("detail sessions = %v", detail["sessions"])
|
||||
}
|
||||
if r, _ := detail["readings"].([]any); len(r) != 2 || obj(r[0])["read_at"] != readAt {
|
||||
t.Errorf("detail readings = %v", detail["readings"])
|
||||
}
|
||||
if m, _ := detail["materials"].([]any); len(m) != 1 || obj(m[0])["title"] != "Notatka" || obj(m[0])["files_count"] != float64(1) {
|
||||
t.Errorf("detail materials = %v", detail["materials"])
|
||||
}
|
||||
if code, _ := alice.do("GET", "/_bm/api/v1/courses/99999"); code != http.StatusNotFound {
|
||||
t.Errorf("missing course = %d, want 404", code)
|
||||
}
|
||||
|
||||
// Schedule, meetings and deadlines.
|
||||
if day := list(alice.ok("GET", "/_bm/api/v1/schedule?from=2030-10-05&to=2030-10-05")); len(day) != 2 {
|
||||
t.Errorf("schedule of 5 Oct = %v", day)
|
||||
}
|
||||
if rest := list(alice.ok("GET", "/_bm/api/v1/schedule?from=2030-10-06")); len(rest) != 1 || rest[0]["is_online"] != true {
|
||||
t.Errorf("schedule from 6 Oct = %v", rest)
|
||||
}
|
||||
if all := list(alice.ok("GET", "/_bm/api/v1/schedule")); len(all) != 3 {
|
||||
t.Errorf("schedule from today = %v", all)
|
||||
}
|
||||
if none := list(alice.ok("GET", "/_bm/api/v1/schedule?to=2026-01-01")); len(none) != 0 {
|
||||
t.Errorf("schedule up to a past day = %v", none)
|
||||
}
|
||||
meetings := list(alice.ok("GET", "/_bm/api/v1/meetings"))
|
||||
if len(meetings) != 2 || meetings[0]["sessions_count"] != float64(2) || meetings[0]["notes"] != "przykład" || meetings[1]["ends_on"] != "2030-10-20" {
|
||||
t.Errorf("meetings = %v", meetings)
|
||||
}
|
||||
if d := list(alice.ok("GET", "/_bm/api/v1/deadlines")); len(d) != 2 || d[0]["kind"] != "organizacyjne" || d[0]["course"] != nil {
|
||||
t.Errorf("deadlines = %v", d)
|
||||
}
|
||||
if d := list(alice.ok("GET", "/_bm/api/v1/deadlines?from=2030-11-01")); len(d) != 1 || obj(d[0]["course"])["form"] != "cwiczenia" {
|
||||
t.Errorf("deadlines from November = %v", d)
|
||||
}
|
||||
|
||||
// Materials: published only, filtered, paginated.
|
||||
page := alice.ok("GET", "/_bm/api/v1/materials")
|
||||
items := list(page)
|
||||
if len(items) != 2 || obj(page["meta"])["total"] != float64(2) || obj(page["meta"])["per_page"] != float64(20) {
|
||||
t.Fatalf("materials = %v", page)
|
||||
}
|
||||
for _, f := range []struct{ query, title string }{
|
||||
{"?tag=geopolityka", "Notatka"},
|
||||
{"?type=link", "Link"},
|
||||
{"?course=" + strconv.FormatUint(uint64(courseAC.ID), 10), "Link"},
|
||||
} {
|
||||
got := list(alice.ok("GET", "/_bm/api/v1/materials"+f.query))
|
||||
if len(got) != 1 || got[0]["title"] != f.title {
|
||||
t.Errorf("materials%s = %v, want %s", f.query, got, f.title)
|
||||
}
|
||||
}
|
||||
if got := list(alice.ok("GET", "/_bm/api/v1/materials?tag=nieznany")); len(got) != 0 {
|
||||
t.Errorf("unknown tag = %v", got)
|
||||
}
|
||||
paged := alice.ok("GET", "/_bm/api/v1/materials?per_page=1&page=2")
|
||||
if got := list(paged); len(got) != 1 || obj(paged["meta"])["last_page"] != float64(2) || obj(paged["meta"])["current_page"] != float64(2) {
|
||||
t.Errorf("page 2 of 2 = %v", paged)
|
||||
}
|
||||
shown := obj(alice.ok("GET", "/_bm/api/v1/materials/"+strconv.FormatUint(uint64(note.ID), 10))["data"])
|
||||
files, _ := shown["files"].([]any)
|
||||
if shown["body"] != "# Nagłówek" || len(files) != 1 || shown["files_count"] != float64(1) || shown["published_at"] != "2026-01-15T12:00:00+01:00" {
|
||||
t.Errorf("material = %v", shown)
|
||||
}
|
||||
if f := obj(files[0]); f["name"] != "wyklad.pdf" || f["size"] != float64(1234) || f["mime"] != "application/pdf" ||
|
||||
!strings.HasPrefix(f["url"].(string), "/storage/uploads/aaa/aaa/aaa/") {
|
||||
t.Errorf("file = %v", f)
|
||||
}
|
||||
if tags, _ := shown["tags"].([]any); len(tags) != 2 || obj(tags[0])["slug"] != "alfa" {
|
||||
t.Errorf("tags = %v", shown["tags"])
|
||||
}
|
||||
for _, hidden := range []uint{draft.ID, later.ID, 99999} {
|
||||
if code, _ := alice.do("GET", "/_bm/api/v1/materials/"+strconv.FormatUint(uint64(hidden), 10)); code != http.StatusNotFound {
|
||||
t.Errorf("material %d = %d, want 404", hidden, code)
|
||||
}
|
||||
}
|
||||
|
||||
// Unmarking clears the state; unmarking again is fine.
|
||||
if cleared := obj(alice.ok("DELETE", "/_bm/api/v1/readings/"+rid+"/progress")["data"]); cleared["read_at"] != nil {
|
||||
t.Errorf("unmark = %v", cleared)
|
||||
}
|
||||
alice.ok("DELETE", "/_bm/api/v1/readings/"+rid+"/progress")
|
||||
if count(t, gdb, "jz_bm_reading_progress") != 0 {
|
||||
t.Error("progress rows remain after unmarking")
|
||||
}
|
||||
|
||||
// Bad parameters answer 422 through the whole stack.
|
||||
code, body := alice.do("GET", "/_bm/api/v1/schedule?from=2030-10-10&to=2030-10-01")
|
||||
if code != http.StatusUnprocessableEntity || obj(body["errors"])["to"] == nil {
|
||||
t.Errorf("bad range = %d %v", code, body)
|
||||
}
|
||||
if msg, _ := body["message"].(string); msg == "" || strings.HasPrefix(msg, "validation.") {
|
||||
t.Errorf("422 message %q is not translated", msg)
|
||||
}
|
||||
|
||||
// Deleting a course cascades to its sessions and readings, and unlinks
|
||||
// its materials.
|
||||
gdb.Exec(`DELETE FROM jz_bm_courses WHERE id = ?`, courseA.ID)
|
||||
if n := count(t, gdb, "jz_bm_readings"); n != 0 {
|
||||
t.Errorf("readings after deleting the course = %d", n)
|
||||
}
|
||||
var orphan models.Material
|
||||
gdb.Where("id = ?", note.ID).Take(&orphan)
|
||||
if orphan.CourseID != nil {
|
||||
t.Errorf("material kept course_id %v", *orphan.CourseID)
|
||||
}
|
||||
}
|
||||
|
||||
func ptr(s string) *string { return &s }
|
||||
Reference in New Issue
Block a user