From ce901d5f26fb1e5bc58716ecea1d6302418d695b Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Fri, 2 Oct 2026 14:26:43 +0200 Subject: [PATCH] feat: add the bm:seed command Validates a whole JSON file (course keys, meeting numbers, values, dates) and upserts meetings, courses, class sessions, deadlines and readings in one transaction, matched on their natural identity. Prints a created, updated and unchanged summary; --dry-run only validates. Ships a small example fixture with made-up data. --- classes/seed.go | 449 +++++++++++++++++++++++++++++++++++++ classes/seed_test.go | 117 ++++++++++ console/seed.go | 58 +++++ console/seed_test.go | 69 ++++++ plugin.go | 26 ++- plugin_test.go | 171 ++++++++++++++ testdata/seed.example.json | 24 ++ 7 files changed, 912 insertions(+), 2 deletions(-) create mode 100644 classes/seed.go create mode 100644 classes/seed_test.go create mode 100644 console/seed.go create mode 100644 console/seed_test.go create mode 100644 plugin_test.go create mode 100644 testdata/seed.example.json diff --git a/classes/seed.go b/classes/seed.go new file mode 100644 index 0000000..8ccbf17 --- /dev/null +++ b/classes/seed.go @@ -0,0 +1,449 @@ +package classes + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "slices" + "strings" + + "git.golem15.com/golem15/summercms/modules/lagoon" + "git.golem15.com/jakub/sm-bm-plugin/models" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// SeedFile is the JSON document bm:seed reads. Every top-level array is +// optional. Courses carry a seed-local key that class sessions, deadlines +// and readings use to point at them; meetings are referenced by number. +type SeedFile struct { + Meetings []SeedMeeting `json:"meetings"` + Courses []SeedCourse `json:"courses"` + ClassSessions []SeedSession `json:"class_sessions"` + Deadlines []SeedDeadline `json:"deadlines"` + Readings []SeedReading `json:"readings"` +} + +// SeedMeeting is one meeting; its identity is Number. +type SeedMeeting struct { + Number int `json:"number"` + StartsOn string `json:"starts_on"` + EndsOn string `json:"ends_on"` + Notes *string `json:"notes"` +} + +// SeedCourse is one course; its identity is Name, Form and Semester. Key is +// only a reference inside the file. +type SeedCourse struct { + Key string `json:"key"` + Code *string `json:"code"` + Name string `json:"name"` + Form string `json:"form"` + Lecturer *string `json:"lecturer"` + Semester int `json:"semester"` + ECTS *float64 `json:"ects"` + Assessment *string `json:"assessment"` + ElectiveGroup *string `json:"elective_group"` + Description *string `json:"description"` + SyllabusURL *string `json:"syllabus_url"` + SortOrder int `json:"sort_order"` +} + +// SeedSession is one class session; its identity is the course and StartsAt. +// Course is a course key, Meeting an optional meeting number. +type SeedSession struct { + Course string `json:"course"` + Meeting *int `json:"meeting"` + StartsAt string `json:"starts_at"` + EndsAt string `json:"ends_at"` + Room *string `json:"room"` + Kind *string `json:"kind"` + IsOnline bool `json:"is_online"` + Notes *string `json:"notes"` +} + +// SeedDeadline is one deadline; its identity is Title and DueAt. Course is +// an optional course key. +type SeedDeadline struct { + Title string `json:"title"` + DueAt string `json:"due_at"` + Course *string `json:"course"` + Kind string `json:"kind"` + Description *string `json:"description"` +} + +// SeedReading is one reading; its identity is the course and Title. +type SeedReading struct { + Course string `json:"course"` + Title string `json:"title"` + Author *string `json:"author"` + Source *string `json:"source"` + URL *string `json:"url"` + IsRequired bool `json:"is_required"` + SortOrder int `json:"sort_order"` +} + +// SeedCounts counts the rows of one kind a seed created, updated or left +// unchanged. +type SeedCounts struct { + Created int + Updated int + Unchanged int +} + +func (c *SeedCounts) record(created, changed bool) { + switch { + case created: + c.Created++ + case changed: + c.Updated++ + default: + c.Unchanged++ + } +} + +// SeedSummary is the outcome of a seed, per kind. +type SeedSummary struct { + Meetings SeedCounts + Courses SeedCounts + ClassSessions SeedCounts + Deadlines SeedCounts + Readings SeedCounts +} + +// Rows returns the summary as table rows: kind, created, updated, unchanged. +func (s SeedSummary) Rows() [][]string { + row := func(name string, c SeedCounts) []string { + return []string{name, fmt.Sprint(c.Created), fmt.Sprint(c.Updated), fmt.Sprint(c.Unchanged)} + } + return [][]string{ + row("meetings", s.Meetings), + row("courses", s.Courses), + row("class_sessions", s.ClassSessions), + row("deadlines", s.Deadlines), + row("readings", s.Readings), + } +} + +// ParseSeed decodes a seed document. Unknown fields are an error, so a typo +// in a key never drops data silently. +func ParseSeed(r io.Reader) (*SeedFile, error) { + body, err := io.ReadAll(r) + if err != nil { + return nil, err + } + dec := json.NewDecoder(bytes.NewReader(body)) + dec.DisallowUnknownFields() + var f SeedFile + if err := dec.Decode(&f); err != nil { + return nil, fmt.Errorf("bm:seed: %w", err) + } + if dec.More() { + return nil, errors.New("bm:seed: trailing data after the JSON document") + } + return &f, nil +} + +// Validate checks the whole document before anything is written: required +// fields, allowed values, dates and times, duplicate identities and that +// every course key and meeting number a record points at exists in the +// file. Each error names the record. +func (f *SeedFile) Validate() error { + var errs []error + add := func(format string, args ...any) { errs = append(errs, fmt.Errorf(format, args...)) } + + meetings := map[int]bool{} + for i, m := range f.Meetings { + at := fmt.Sprintf("meetings[%d] (number %d)", i, m.Number) + if m.Number < 1 { + add("%s: number must be a positive integer", at) + } + if meetings[m.Number] { + add("%s: duplicate meeting number", at) + } + meetings[m.Number] = true + start, err1 := models.ParseDate(m.StartsOn) + end, err2 := models.ParseDate(m.EndsOn) + if err1 != nil { + add("%s: starts_on: %v", at, err1) + } + if err2 != nil { + add("%s: ends_on: %v", at, err2) + } + if err1 == nil && err2 == nil && end.Before(start) { + add("%s: ends_on is before starts_on", at) + } + } + + keys := map[string]bool{} + identities := map[string]bool{} + for i, c := range f.Courses { + at := fmt.Sprintf("courses[%d] (key %q)", i, c.Key) + if strings.TrimSpace(c.Key) == "" { + add("%s: key is required", at) + } else if keys[c.Key] { + add("%s: duplicate course key", at) + } + keys[c.Key] = true + if strings.TrimSpace(c.Name) == "" { + add("%s: name is required", at) + } + if !slices.Contains(models.CourseForms, c.Form) { + add("%s: form %q is not one of %s", at, c.Form, strings.Join(models.CourseForms, ", ")) + } + if c.Semester < 1 || c.Semester > 4 { + add("%s: semester must be between 1 and 4", at) + } + id := fmt.Sprintf("%s\x00%s\x00%d", c.Name, c.Form, c.Semester) + if identities[id] { + add("%s: another course has the same name, form and semester", at) + } + identities[id] = true + } + + sessions := map[string]bool{} + for i, s := range f.ClassSessions { + at := fmt.Sprintf("class_sessions[%d] (course %q, starts_at %s)", i, s.Course, s.StartsAt) + if !keys[s.Course] { + add("%s: unknown course key %q", at, s.Course) + } + if s.Meeting != nil && !meetings[*s.Meeting] { + add("%s: unknown meeting number %d", at, *s.Meeting) + } + start, err1 := models.ParseDateTime(s.StartsAt) + end, err2 := models.ParseDateTime(s.EndsAt) + if err1 != nil { + add("%s: starts_at: %v", at, err1) + } + if err2 != nil { + add("%s: ends_at: %v", at, err2) + } + if err1 == nil && err2 == nil { + if !end.After(start) { + add("%s: ends_at is not after starts_at", at) + } + id := s.Course + "\x00" + start.UTC().String() + if sessions[id] { + add("%s: duplicate session of the course at this time", at) + } + sessions[id] = true + } + } + + deadlines := map[string]bool{} + for i, d := range f.Deadlines { + at := fmt.Sprintf("deadlines[%d] (%q)", i, d.Title) + if strings.TrimSpace(d.Title) == "" { + add("%s: title is required", at) + } + if d.Course != nil && !keys[*d.Course] { + add("%s: unknown course key %q", at, *d.Course) + } + if !slices.Contains(models.DeadlineKinds, d.Kind) { + add("%s: kind %q is not one of %s", at, d.Kind, strings.Join(models.DeadlineKinds, ", ")) + } + due, err := models.ParseDateTime(d.DueAt) + if err != nil { + add("%s: due_at: %v", at, err) + continue + } + id := d.Title + "\x00" + due.UTC().String() + if deadlines[id] { + add("%s: duplicate deadline (same title and due_at)", at) + } + deadlines[id] = true + } + + readings := map[string]bool{} + for i, r := range f.Readings { + at := fmt.Sprintf("readings[%d] (course %q, %q)", i, r.Course, r.Title) + if !keys[r.Course] { + add("%s: unknown course key %q", at, r.Course) + } + if strings.TrimSpace(r.Title) == "" { + add("%s: title is required", at) + } + id := r.Course + "\x00" + r.Title + if readings[id] { + add("%s: duplicate reading of the course", at) + } + readings[id] = true + } + return errors.Join(errs...) +} + +// Seed validates f and upserts it in one transaction: meetings by number, +// courses by name, form and semester, class sessions by course and start +// time, deadlines by title and due time, readings by course and title. Rows +// absent from the file are left alone. On any error nothing is written. +func Seed(ctx context.Context, db *gorm.DB, f *SeedFile) (SeedSummary, error) { + var sum SeedSummary + if err := f.Validate(); err != nil { + return sum, err + } + err := lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error { + meetingIDs := map[int]uint{} + for _, in := range f.Meetings { + m := models.Meeting{} + created, changed, err := upsert(tx, &m, clause.Eq{Column: "number", Value: in.Number}, func(m *models.Meeting) { + m.Number = in.Number + start, _ := models.ParseDate(in.StartsOn) + end, _ := models.ParseDate(in.EndsOn) + m.StartsOn = models.NewDate(start) + m.EndsOn = models.NewDate(end) + m.Notes = nz(in.Notes) + }) + if err != nil { + return fmt.Errorf("meeting %d: %w", in.Number, err) + } + sum.Meetings.record(created, changed) + meetingIDs[in.Number] = m.ID + } + + courseIDs := map[string]uint{} + for _, in := range f.Courses { + c := models.Course{} + where := clause.And( + clause.Eq{Column: "name", Value: in.Name}, + clause.Eq{Column: "form", Value: in.Form}, + clause.Eq{Column: "semester", Value: in.Semester}, + ) + created, changed, err := upsert(tx, &c, where, func(c *models.Course) { + c.Code = nz(in.Code) + c.Name = in.Name + c.Form = in.Form + c.Lecturer = nz(in.Lecturer) + c.Semester = in.Semester + c.ECTS = in.ECTS + c.Assessment = nz(in.Assessment) + c.ElectiveGroup = nz(in.ElectiveGroup) + c.Description = nz(in.Description) + c.SyllabusURL = nz(in.SyllabusURL) + c.SortOrder = in.SortOrder + }) + if err != nil { + return fmt.Errorf("course %q: %w", in.Key, err) + } + sum.Courses.record(created, changed) + courseIDs[in.Key] = c.ID + } + + for _, in := range f.ClassSessions { + start, _ := models.ParseDateTime(in.StartsAt) + s := models.ClassSession{} + where := clause.And( + clause.Eq{Column: "course_id", Value: courseIDs[in.Course]}, + clause.Eq{Column: "starts_at", Value: start.UTC()}, + ) + created, changed, err := upsert(tx, &s, where, func(s *models.ClassSession) { + s.CourseID = courseIDs[in.Course] + s.MeetingID = nil + if in.Meeting != nil { + id := meetingIDs[*in.Meeting] + s.MeetingID = &id + } + s.StartsAt = models.NewDateTime(start) + end, _ := models.ParseDateTime(in.EndsAt) + s.EndsAt = models.NewDateTime(end) + s.Room = nz(in.Room) + s.Kind = nz(in.Kind) + s.IsOnline = in.IsOnline + s.Notes = nz(in.Notes) + }) + if err != nil { + return fmt.Errorf("class session %s %s: %w", in.Course, in.StartsAt, err) + } + sum.ClassSessions.record(created, changed) + } + + for _, in := range f.Deadlines { + due, _ := models.ParseDateTime(in.DueAt) + d := models.Deadline{} + where := clause.And( + clause.Eq{Column: "title", Value: in.Title}, + clause.Eq{Column: "due_at", Value: due.UTC()}, + ) + created, changed, err := upsert(tx, &d, where, func(d *models.Deadline) { + d.Title = in.Title + d.DueAt = models.NewDateTime(due) + d.CourseID = nil + if in.Course != nil { + id := courseIDs[*in.Course] + d.CourseID = &id + } + d.Kind = in.Kind + d.Description = nz(in.Description) + }) + if err != nil { + return fmt.Errorf("deadline %q: %w", in.Title, err) + } + sum.Deadlines.record(created, changed) + } + + for _, in := range f.Readings { + r := models.Reading{} + where := clause.And( + clause.Eq{Column: "course_id", Value: courseIDs[in.Course]}, + clause.Eq{Column: "title", Value: in.Title}, + ) + created, changed, err := upsert(tx, &r, where, func(r *models.Reading) { + r.CourseID = courseIDs[in.Course] + r.Title = in.Title + r.Author = nz(in.Author) + r.Source = nz(in.Source) + r.URL = nz(in.URL) + r.IsRequired = in.IsRequired + r.SortOrder = in.SortOrder + }) + if err != nil { + return fmt.Errorf("reading %q of %s: %w", in.Title, in.Course, err) + } + sum.Readings.record(created, changed) + } + return nil + }) + if err != nil { + return SeedSummary{}, err + } + return sum, nil +} + +// nz returns nil for a missing or blank optional text, as the models' +// BeforeSave hooks store it, so an unchanged row compares equal. +func nz(s *string) *string { + if s == nil || strings.TrimSpace(*s) == "" { + return nil + } + return s +} + +// upsert loads the row matching where into row, applies set and writes it: +// an insert when there was no row, an update when set changed a column, and +// nothing otherwise. The comparison is on the row's own fields, before and +// after set, as JSON. +func upsert[T any](tx *gorm.DB, row *T, where clause.Expression, set func(*T)) (created, changed bool, err error) { + res := tx.Clauses(where).Limit(1).Find(row) + if res.Error != nil { + return false, false, res.Error + } + if res.RowsAffected == 0 { + set(row) + return true, false, tx.Create(row).Error + } + before, err := json.Marshal(row) + if err != nil { + return false, false, err + } + set(row) + after, err := json.Marshal(row) + if err != nil { + return false, false, err + } + if bytes.Equal(before, after) { + return false, false, nil + } + return false, true, tx.Save(row).Error +} diff --git a/classes/seed_test.go b/classes/seed_test.go new file mode 100644 index 0000000..f2c387e --- /dev/null +++ b/classes/seed_test.go @@ -0,0 +1,117 @@ +package classes_test + +import ( + "os" + "strings" + "testing" + + "git.golem15.com/jakub/sm-bm-plugin/classes" +) + +func parse(t *testing.T, doc string) *classes.SeedFile { + t.Helper() + f, err := classes.ParseSeed(strings.NewReader(doc)) + if err != nil { + t.Fatalf("ParseSeed: %v", err) + } + return f +} + +func TestExampleFixtureIsValid(t *testing.T) { + fh, err := os.Open("../testdata/seed.example.json") + if err != nil { + t.Fatal(err) + } + defer fh.Close() + f, err := classes.ParseSeed(fh) + if err != nil { + t.Fatal(err) + } + if err := f.Validate(); err != nil { + t.Fatalf("Validate: %v", err) + } + if len(f.Meetings) != 2 || len(f.Courses) != 3 || len(f.ClassSessions) != 3 || len(f.Deadlines) != 2 || len(f.Readings) != 2 { + t.Errorf("fixture counts = %d %d %d %d %d", len(f.Meetings), len(f.Courses), len(f.ClassSessions), len(f.Deadlines), len(f.Readings)) + } + if f.Courses[2].ElectiveGroup == nil || *f.Courses[2].ElectiveGroup != "przyklad-do-wyboru" { + t.Errorf("elective_group = %v", f.Courses[2].ElectiveGroup) + } +} + +func TestEmptyDocumentIsValid(t *testing.T) { + if err := parse(t, `{}`).Validate(); err != nil { + t.Errorf("{}: %v", err) + } +} + +func TestParseRejectsUnknownFieldsAndTrailingData(t *testing.T) { + for _, doc := range []string{ + `{"courses": [{"key": "a", "nmae": "typo"}]}`, + `{"lectures": []}`, + `{} {}`, + `[]`, + `{"meetings": [{"number": "one"}]}`, + } { + if _, err := classes.ParseSeed(strings.NewReader(doc)); err == nil { + t.Errorf("ParseSeed(%s) accepted", doc) + } + } +} + +func TestValidateNamesTheRecord(t *testing.T) { + cases := []struct { + doc string + want []string + }{ + {`{"class_sessions": [{"course": "ghost", "starts_at": "2030-10-05T08:00:00+02:00", "ends_at": "2030-10-05T09:00:00+02:00"}]}`, + []string{`class_sessions[0]`, `unknown course key "ghost"`}}, + {`{"courses": [{"key": "a", "name": "A", "form": "wyklad", "semester": 1}], + "class_sessions": [{"course": "a", "meeting": 9, "starts_at": "2030-10-05T08:00:00+02:00", "ends_at": "2030-10-05T09:00:00+02:00"}]}`, + []string{`unknown meeting number 9`}}, + {`{"deadlines": [{"title": "X", "due_at": "2030-10-05T08:00:00+02:00", "course": "nope", "kind": "inne"}]}`, + []string{`deadlines[0] ("X")`, `unknown course key "nope"`}}, + {`{"readings": [{"course": "nope", "title": "Book"}]}`, + []string{`readings[0]`, `unknown course key "nope"`}}, + {`{"courses": [{"key": "a", "name": "A", "form": "lecture", "semester": 7}]}`, + []string{`form "lecture"`, `semester must be between 1 and 4`}}, + {`{"courses": [{"key": "a", "name": "A", "form": "wyklad", "semester": 1}, {"key": "a", "name": "A", "form": "wyklad", "semester": 1}]}`, + []string{`duplicate course key`, `same name, form and semester`}}, + {`{"meetings": [{"number": 1, "starts_on": "2030-10-06", "ends_on": "2030-10-05"}, {"number": 1, "starts_on": "05.10.2030", "ends_on": "2030-10-05"}]}`, + []string{`ends_on is before starts_on`, `duplicate meeting number`, `starts_on`}}, + {`{"courses": [{"key": "a", "name": "A", "form": "wyklad", "semester": 1}], + "class_sessions": [{"course": "a", "starts_at": "2030-10-05 10:00", "ends_at": "2030-10-05 09:00"}]}`, + []string{`ends_at is not after starts_at`}}, + {`{"deadlines": [{"title": "", "due_at": "soon", "kind": "deadline"}]}`, + []string{`title is required`, `due_at`, `kind "deadline"`}}, + } + for _, tc := range cases { + err := parse(t, tc.doc).Validate() + if err == nil { + t.Errorf("Validate(%s) passed", tc.doc) + continue + } + for _, want := range tc.want { + if !strings.Contains(err.Error(), want) { + t.Errorf("Validate(%s) = %q, want it to mention %q", tc.doc, err, want) + } + } + } +} + +func TestSeedValidatesBeforeTouchingTheDatabase(t *testing.T) { + f := parse(t, `{"readings": [{"course": "ghost", "title": "Book"}]}`) + // A nil database would panic if Seed reached it. + if _, err := classes.Seed(t.Context(), nil, f); err == nil || !strings.Contains(err.Error(), "ghost") { + t.Errorf("Seed = %v, want the unknown course error", err) + } +} + +func TestSummaryRows(t *testing.T) { + var s classes.SeedSummary + s.Courses.Created = 2 + s.Readings.Unchanged = 1 + rows := s.Rows() + if len(rows) != 5 || rows[1][0] != "courses" || rows[1][1] != "2" || rows[4][3] != "1" { + t.Errorf("Rows = %v", rows) + } +} diff --git a/console/seed.go b/console/seed.go new file mode 100644 index 0000000..475d0a2 --- /dev/null +++ b/console/seed.go @@ -0,0 +1,58 @@ +// Code generated by summer make. DO NOT EDIT. + +package console + +import ( + "context" + "fmt" + "os" + + "git.golem15.com/golem15/summercms/modules/bonfire" + "git.golem15.com/jakub/sm-bm-plugin/classes" + "gorm.io/gorm" +) + +// SeedCommand returns the bm:seed console command. withDB runs the +// command's work with the application's database; the plugin supplies it. +func SeedCommand(withDB func(ctx context.Context, fn func(*gorm.DB) error) error) bonfire.Command { + return bonfire.Command{ + Name: "bm:seed", + Description: "Upsert meetings, courses, class sessions, deadlines and readings from a JSON file", + Args: []bonfire.Arg{{Name: "file", Description: "Path of the seed JSON file", Required: true}}, + Flags: []bonfire.Flag{ + {Name: "dry-run", Description: "Validate the file without writing", Bare: true}, + }, + Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error { + path, _ := in.Argument("file") + if path == "" { + return fmt.Errorf("bm:seed: a file is required") + } + fh, err := os.Open(path) + if err != nil { + return fmt.Errorf("bm:seed: %w", err) + } + defer fh.Close() + file, err := classes.ParseSeed(fh) + if err != nil { + return err + } + if err := file.Validate(); err != nil { + return fmt.Errorf("bm:seed: %s is not valid, nothing was written:\n%w", path, err) + } + if dry, _ := in.Flag("dry-run"); dry == "true" { + out.Success(fmt.Sprintf("%s is valid: %d meetings, %d courses, %d class sessions, %d deadlines, %d readings", path, + len(file.Meetings), len(file.Courses), len(file.ClassSessions), len(file.Deadlines), len(file.Readings))) + return nil + } + return withDB(ctx, func(db *gorm.DB) error { + sum, err := classes.Seed(ctx, db, file) + if err != nil { + return fmt.Errorf("bm:seed: nothing was written: %w", err) + } + out.Table([]string{"kind", "created", "updated", "unchanged"}, sum.Rows()) + out.Success("bm:seed: " + path + " applied") + return nil + }) + }, + } +} diff --git a/console/seed_test.go b/console/seed_test.go new file mode 100644 index 0000000..4a33296 --- /dev/null +++ b/console/seed_test.go @@ -0,0 +1,69 @@ +package console_test + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "git.golem15.com/golem15/summercms/modules/bonfire" + "git.golem15.com/jakub/sm-bm-plugin/console" + "gorm.io/gorm" +) + +func TestSeedCommandShape(t *testing.T) { + cmd := console.SeedCommand(nil) + if cmd.Name != "bm:seed" || cmd.Description == "" { + t.Errorf("command = %q %q", cmd.Name, cmd.Description) + } + if len(cmd.Args) != 1 || cmd.Args[0].Name != "file" || !cmd.Args[0].Required { + t.Errorf("Args = %+v, want one required file argument", cmd.Args) + } +} + +func TestSeedCommandChecksTheFileBeforeTheDatabase(t *testing.T) { + called := false + withDB := func(ctx context.Context, fn func(*gorm.DB) error) error { + called = true + return errors.New("no database in this test") + } + cmds := []bonfire.Command{console.SeedCommand(withDB)} + dir := t.TempDir() + invalid := filepath.Join(dir, "invalid.json") + if err := os.WriteFile(invalid, []byte(`{"readings": [{"course": "ghost", "title": "X"}]}`), 0o644); err != nil { + t.Fatal(err) + } + broken := filepath.Join(dir, "broken.json") + if err := os.WriteFile(broken, []byte(`{"courses": [`), 0o644); err != nil { + t.Fatal(err) + } + var out bytes.Buffer + for _, args := range [][]string{nil, {filepath.Join(dir, "missing.json")}, {broken}, {invalid}} { + if err := bonfire.Call(context.Background(), cmds, "bm:seed", args, &out); err == nil { + t.Errorf("bm:seed %v succeeded", args) + } + } + err := bonfire.Call(context.Background(), cmds, "bm:seed", []string{invalid}, &out) + if err == nil || !strings.Contains(err.Error(), "nothing was written") || !strings.Contains(err.Error(), "ghost") { + t.Errorf("invalid file error = %v", err) + } + if called { + t.Error("bm:seed reached the database with an invalid file") + } + + out.Reset() + if err := bonfire.Call(context.Background(), cmds, "bm:seed", []string{"../testdata/seed.example.json", "--dry-run"}, &out); err != nil { + t.Fatalf("dry run: %v", err) + } + if called || !strings.Contains(out.String(), "3 courses") { + t.Errorf("dry run called=%v output %q", called, out.String()) + } + + err = bonfire.Call(context.Background(), cmds, "bm:seed", []string{"../testdata/seed.example.json"}, &out) + if !called || err == nil { + t.Errorf("valid file: called=%v err=%v, want the withDB error", called, err) + } +} diff --git a/plugin.go b/plugin.go index 15d06d9..852133d 100644 --- a/plugin.go +++ b/plugin.go @@ -1,15 +1,19 @@ package bm import ( + "context" "embed" "io/fs" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/bonfire" + "git.golem15.com/golem15/summercms/modules/lagoon" "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/party" + "git.golem15.com/jakub/sm-bm-plugin/console" "git.golem15.com/jakub/sm-bm-plugin/controllers" "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/gorm" ) var ( @@ -76,8 +80,11 @@ func (p *Plugin) AdminControllers() []pact.AdminController { return generatedAdminControllers() } -// Commands returns the generated commands. -func (p *Plugin) Commands() []bonfire.Command { return generatedCommands() } +// Commands returns the generated commands plus bm:seed, which needs the +// database and so is built here with the plugin's withDB. +func (p *Plugin) Commands() []bonfire.Command { + return append(generatedCommands(), console.SeedCommand(p.withDB)) +} // AdminFS is the admin YAML the controllers read. func (p *Plugin) AdminFS() fs.FS { return adminFS } @@ -114,6 +121,21 @@ func (p *Plugin) Navigation() []pact.NavigationItem { }} } +// withDB runs fn with the application's database: the one the serve command +// published, or, when a console command runs, one opened from the config for +// the duration of fn. +func (p *Plugin) withDB(ctx context.Context, fn func(*gorm.DB) error) error { + if gdb, ok := p.app.Lookup[*gorm.DB](); ok && gdb != nil { + return fn(gdb) + } + sqlDB, gdb, err := lagoon.OpenFromApp(ctx, p.app) + if err != nil { + return err + } + defer sqlDB.Close() + return fn(gdb) +} + func init() { party.Register(&Plugin{}) } diff --git a/plugin_test.go b/plugin_test.go new file mode 100644 index 0000000..bd4ee6c --- /dev/null +++ b/plugin_test.go @@ -0,0 +1,171 @@ +package bm_test + +import ( + "os" + "path/filepath" + "slices" + "testing" + + _ "git.golem15.com/golem15/sm-user-plugin" + "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/cabana" + "git.golem15.com/golem15/summercms/modules/compass" + "git.golem15.com/golem15/summercms/modules/pact" + "git.golem15.com/golem15/summercms/modules/party" + "git.golem15.com/golem15/summercms/modules/surf" + bm "git.golem15.com/jakub/sm-bm-plugin" +) + +const ( + testUserSecret = "jz-bm-test-only-user-jwt-secret-32bytes" + testAdminSecret = "jz-bm-test-only-admin-jwt-secret-32bytes" +) + +// testConfig is the application config the tests boot with: the HTTP +// limits, the golem15.user JWT secret and the admin secret. +func testConfig(t *testing.T) *compass.Config { + t.Helper() + dir := t.TempDir() + httpYAML := "body_limits:\n default_bytes: 1048576\n upload_bytes: 10485760\n" + if err := os.WriteFile(filepath.Join(dir, "http.yaml"), []byte(httpYAML), 0o644); err != nil { + t.Fatal(err) + } + cfg, err := compass.Open(compass.Options{ + Dir: dir, + Environ: []string{ + "SUMMER_ENV=testing", + "SUMMER_GOLEM15__USER__JWT__SECRET=" + testUserSecret, + "SUMMER_ADMIN__JWT__SECRET=" + testAdminSecret, + }, + }) + if err != nil { + t.Fatal(err) + } + return cfg +} + +// activate boots golem15.user and jz.bm the way the generated main does. +func activate(t *testing.T, app *backpack.App) []party.Plugin { + t.Helper() + plugins, err := party.Activate(app, []string{"golem15.user", "jz.bm"}) + if err != nil { + t.Fatalf("Activate: %v", err) + } + return plugins +} + +func bmPlugin(t *testing.T, plugins []party.Plugin) *bm.Plugin { + t.Helper() + for _, p := range plugins { + if b, ok := p.(*bm.Plugin); ok { + return b + } + } + t.Fatal("jz.bm was not activated") + return nil +} + +func TestPluginActivates(t *testing.T) { + app := backpack.New(testConfig(t)) + p := bmPlugin(t, activate(t, app)) + if p.ID() != "jz.bm" || !slices.Equal(p.Requires(), []string{"golem15.user"}) { + t.Errorf("ID %q Requires %v", p.ID(), p.Requires()) + } + if n := len(p.Models()); n != 9 { + t.Errorf("Models = %d, want 9", n) + } + if n := len(p.Migrations()); n != 10 { + t.Errorf("Migrations = %d, want 10", n) + } + if !slices.ContainsFunc(p.Commands(), func(c bonfire.Command) bool { return c.Name == "bm:seed" }) { + t.Error("bm:seed is not registered") + } + if got := app.Config.Int("jz.bm.materials_per_page"); got != 20 { + t.Errorf("jz.bm.materials_per_page = %d, want the default 20", got) + } + if got := app.Config.Int("jz.bm.dashboard_limit"); got != 5 { + t.Errorf("jz.bm.dashboard_limit = %d, want the default 5", got) + } +} + +func TestRoutes(t *testing.T) { + app := backpack.New(testConfig(t)) + plugins := activate(t, app) + router, err := surf.BuildRouter(app, plugins) + if err != nil { + t.Fatalf("BuildRouter: %v", err) + } + want := []string{ + "GET /_bm/api/v1/dashboard", + "GET /_bm/api/v1/courses", + "GET /_bm/api/v1/courses/{id}", + "GET /_bm/api/v1/schedule", + "GET /_bm/api/v1/meetings", + "GET /_bm/api/v1/deadlines", + "GET /_bm/api/v1/readings", + "PUT /_bm/api/v1/readings/{id}/progress", + "DELETE /_bm/api/v1/readings/{id}/progress", + "GET /_bm/api/v1/materials", + "GET /_bm/api/v1/materials/{id}", + } + var got []string + for _, rt := range router.Routes() { + if rt.PluginID != "jz.bm" { + continue + } + got = append(got, rt.Method+" "+rt.Pattern) + if !slices.Contains(rt.Middleware, "jwt.auth") { + t.Errorf("%s %s is not behind jwt.auth: %v", rt.Method, rt.Pattern, rt.Middleware) + } + } + if !slices.Equal(got, want) { + t.Errorf("routes =\n%v\nwant\n%v", got, want) + } +} + +func TestAdminCompiles(t *testing.T) { + app := backpack.New(testConfig(t)) + plugins := activate(t, app) + p := bmPlugin(t, plugins) + routes, err := cabana.Activate(app, plugins) + if err != nil { + t.Fatalf("cabana.Activate: %v", err) + } + if routes == nil { + t.Fatal("no admin routes") + } + declared := map[string]bool{} + for _, perm := range p.Permissions() { + declared[perm.Code] = true + } + for _, ctl := range p.AdminControllers() { + perms := ctl.(pact.AdminPermissioned).RequiredPermissions() + for _, code := range perms { + if !declared[code] { + t.Errorf("%s requires undeclared %s", ctl.ID(), code) + } + } + if rec := ctl.(pact.AdminRecordSource).NewRecord(); rec == nil { + t.Errorf("%s has no record model", ctl.ID()) + } + form, err := cabana.CompileForm("jz.bm", ctl, p.AdminFS()) + if err != nil { + t.Errorf("%s form: %v", ctl.ID(), err) + } else if len(form.Fields) == 0 { + t.Errorf("%s form has no fields", ctl.ID()) + } + if _, err := cabana.CompileList("jz.bm", ctl, p.AdminFS()); err != nil { + t.Errorf("%s list: %v", ctl.ID(), err) + } + editor := &bouncer.Principal{ID: 1, Backend: true, PermissionGrants: map[string]bool{"jz.bm.*": true}} + if !cabana.Allows(editor, perms) { + t.Errorf("jz.bm.* does not open %s", ctl.ID()) + } + } + nav := p.Navigation() + if len(nav) != 1 || len(nav[0].SideMenu) != 7 { + t.Errorf("navigation = %+v, want one group with 7 side items", nav) + } +} diff --git a/testdata/seed.example.json b/testdata/seed.example.json new file mode 100644 index 0000000..b576b3f --- /dev/null +++ b/testdata/seed.example.json @@ -0,0 +1,24 @@ +{ + "meetings": [ + {"number": 1, "starts_on": "2030-10-05", "ends_on": "2030-10-06", "notes": "przykład"}, + {"number": 2, "starts_on": "2030-10-19", "ends_on": "2030-10-20"} + ], + "courses": [ + {"key": "a-w", "code": null, "name": "Przedmiot przykładowy A", "form": "wyklad", "lecturer": "dr Jan Przykładowy", "semester": 1, "ects": 4, "assessment": "egzamin", "elective_group": null, "description": null, "syllabus_url": null, "sort_order": 1}, + {"key": "a-c", "name": "Przedmiot przykładowy A", "form": "cwiczenia", "semester": 1, "ects": 2.5, "assessment": "zaliczenie na ocenę", "sort_order": 2}, + {"key": "b-k", "name": "Przedmiot przykładowy B", "form": "konwersatorium", "semester": 1, "elective_group": "przyklad-do-wyboru", "sort_order": 3} + ], + "class_sessions": [ + {"course": "a-w", "meeting": 1, "starts_at": "2030-10-05T08:00:00+02:00", "ends_at": "2030-10-05T09:30:00+02:00", "room": "101", "kind": null, "is_online": false, "notes": "dr Jan Przykładowy"}, + {"course": "a-c", "meeting": 1, "starts_at": "2030-10-05T09:45:00+02:00", "ends_at": "2030-10-05T11:15:00+02:00", "room": "102"}, + {"course": "b-k", "meeting": 2, "starts_at": "2030-10-19T08:00:00+02:00", "ends_at": "2030-10-19T09:30:00+02:00", "is_online": true} + ], + "deadlines": [ + {"title": "Termin przykładowy", "due_at": "2030-10-07T23:59:00+02:00", "course": null, "kind": "organizacyjne", "description": null}, + {"title": "Esej przykładowy", "due_at": "2030-11-30T23:59:00+01:00", "course": "a-c", "kind": "praca"} + ], + "readings": [ + {"course": "a-w", "title": "Lektura przykładowa 1", "author": "A. Autor", "source": null, "url": null, "is_required": true, "sort_order": 1}, + {"course": "a-w", "title": "Lektura przykładowa 2", "is_required": false, "sort_order": 2} + ] +}