From 7889621b69799e7eaec027ec3caeb1abdd14b6e5 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Fri, 2 Oct 2026 14:27:01 +0200 Subject: [PATCH] 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. --- admin_test.go | 185 +++++++++++++ postgres_test.go | 661 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 846 insertions(+) create mode 100644 admin_test.go create mode 100644 postgres_test.go diff --git a/admin_test.go b/admin_test.go new file mode 100644 index 0000000..876b524 --- /dev/null +++ b/admin_test.go @@ -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 +} diff --git a/postgres_test.go b/postgres_test.go new file mode 100644 index 0000000..1effaf9 --- /dev/null +++ b/postgres_test.go @@ -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 }