Files
sm-bm-plugin/postgres_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

723 lines
28 KiB
Go

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", "jz_bm_calendar_feeds",
}
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.Equal(time.Date(2030, 10, 5, 6, 0, 0, 0, time.UTC)) || 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 := time.Date(2026, 1, 15, 11, 0, 0, 0, time.UTC)
future := time.Date(2099, 1, 1, 11, 0, 0, 0, time.UTC)
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{&note, &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 }
// TestSeedPrune moves a session, drops a course, renames a course's form and
// drops a reading: --dry-run reports it without writing, --prune applies it.
func TestSeedPrune(t *testing.T) {
_, plugins, gdb := migrated(t)
seedFixture(t, plugins, "testdata/seed.example.json")
next := filepath.Join(t.TempDir(), "next.json")
writeJSON(t, next, `{"meetings": [{"number": 1, "starts_on": "2030-10-05", "ends_on": "2030-10-06", "notes": "przykład"}],
"courses": [{"key": "a-w", "name": "Przedmiot przykładowy A", "form": "wyklad", "semester": 1, "sort_order": 1},
{"key": "b", "name": "Przedmiot przykładowy B", "form": "inne", "semester": 1, "sort_order": 3}],
"class_sessions": [{"course": "a-w", "meeting": 1, "starts_at": "2030-10-05T08:15:00+02:00", "ends_at": "2030-10-05T09:45:00+02:00"},
{"course": "b", "meeting": 1, "starts_at": "2030-10-06T08:00:00+02:00", "ends_at": "2030-10-06T09:30:00+02:00"}],
"readings": [{"course": "a-w", "title": "Lektura przykładowa 1", "is_required": true, "sort_order": 1}]}`)
before := map[string]int64{}
for _, table := range []string{"jz_bm_courses", "jz_bm_class_sessions", "jz_bm_readings", "jz_bm_meetings", "jz_bm_deadlines"} {
before[table] = count(t, gdb, table)
}
var buf bytes.Buffer
if err := bonfire.Call(t.Context(), bmPlugin(t, plugins).Commands(), "bm:seed", []string{next, "--prune", "--dry-run"}, &buf); err != nil {
t.Fatalf("dry run: %v\n%s", err, buf.String())
}
out := buf.String()
for _, want := range []string{"courses\t1\t1\t0\t2", "class_sessions\t2\t0\t0\t3", "readings\t0\t1\t0\t1", "nothing was written", `deleted: course "Przedmiot przykładowy A" (cwiczenia, semester 1) with 1 sessions and 0 readings`} {
if !strings.Contains(out, want) {
t.Errorf("dry run output lacks %q:\n%s", want, out)
}
}
for table, n := range before {
if got := count(t, gdb, table); got != n {
t.Errorf("dry run changed %s: %d -> %d", table, n, got)
}
}
buf.Reset()
if err := bonfire.Call(t.Context(), bmPlugin(t, plugins).Commands(), "bm:seed", []string{next, "--prune"}, &buf); err != nil {
t.Fatalf("prune: %v\n%s", err, buf.String())
}
want := map[string]int64{"jz_bm_courses": 2, "jz_bm_class_sessions": 2, "jz_bm_readings": 1, "jz_bm_meetings": 2, "jz_bm_deadlines": 2}
for table, n := range want {
if got := count(t, gdb, table); got != n {
t.Errorf("after prune %s = %d rows, want %d", table, got, n)
}
}
var orphan int64
gdb.Model(&models.Deadline{}).Where("title = ? AND course_id IS NULL", "Esej przykładowy").Count(&orphan)
if orphan != 1 {
t.Error("the deadline of the pruned course was not unlinked")
}
// Without --prune the same file deletes nothing.
seedFixture(t, plugins, "testdata/seed.example.json")
buf.Reset()
if err := bonfire.Call(t.Context(), bmPlugin(t, plugins).Commands(), "bm:seed", []string{next}, &buf); err != nil {
t.Fatal(err)
}
// The fixture brought back course a-c (its session) and b-k next to b.
if got := count(t, gdb, "jz_bm_courses"); got != 4 || strings.Contains(buf.String(), "deleted:") {
t.Errorf("seed without --prune deleted rows: %d courses\n%s", got, buf.String())
}
}