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