The schedule showed a lecturer only where the session notes named one (Globalizacja), so the student plan had nobody under the other courses. The session JSON now carries the course lecturer in its course ref and an optional per-session lecturer (new nullable column, admin field, seed key) for courses shared between two people. The student app shows the session's own lecturer or, failing that, the course's.
724 lines
28 KiB
Go
724 lines
28 KiB
Go
package bm_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"database/sql"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
|
"git.golem15.com/golem15/summercms/modules/bonfire"
|
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
|
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
|
"git.golem15.com/golem15/summercms/modules/party"
|
|
"git.golem15.com/golem15/summercms/modules/surf"
|
|
"git.golem15.com/jakub/sm-bm-plugin/models"
|
|
_ "github.com/jackc/pgx/v5/stdlib"
|
|
"github.com/testcontainers/testcontainers-go"
|
|
"github.com/testcontainers/testcontainers-go/modules/postgres"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// The database tests run against a throwaway testcontainers Postgres, never
|
|
// a developer database. Under -short the container is not started and the
|
|
// tests skip; in a full run a missing Docker daemon fails the package.
|
|
var (
|
|
pgContainer *postgres.PostgresContainer
|
|
pgAdmin *sql.DB
|
|
pgDSN string
|
|
)
|
|
|
|
func TestMain(m *testing.M) {
|
|
if !isShort() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
|
err := startPostgres(ctx)
|
|
cancel()
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "jz.bm: testcontainers postgres: %v\n", err)
|
|
stopPostgres()
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
code := m.Run()
|
|
stopPostgres()
|
|
os.Exit(code)
|
|
}
|
|
|
|
func isShort() bool {
|
|
for _, a := range os.Args {
|
|
if a == "-test.short" || a == "-test.short=true" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func startPostgres(ctx context.Context) error {
|
|
ctr, err := postgres.Run(ctx,
|
|
"postgres:16-alpine",
|
|
postgres.WithDatabase("bm"),
|
|
postgres.WithUsername("bm"),
|
|
postgres.WithPassword("bm"),
|
|
postgres.BasicWaitStrategies(),
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
pgContainer = ctr
|
|
dsn, err := ctr.ConnectionString(ctx, "sslmode=disable")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
db, err := sql.Open("pgx", dsn)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := db.PingContext(ctx); err != nil {
|
|
_ = db.Close()
|
|
return err
|
|
}
|
|
pgAdmin, pgDSN = db, dsn
|
|
return nil
|
|
}
|
|
|
|
func stopPostgres() {
|
|
if pgAdmin != nil {
|
|
_ = pgAdmin.Close()
|
|
}
|
|
if pgContainer != nil {
|
|
_ = testcontainers.TerminateContainer(pgContainer)
|
|
}
|
|
}
|
|
|
|
// testDatabase creates a database for one test and drops it afterwards.
|
|
func testDatabase(t *testing.T) (*sql.DB, string) {
|
|
t.Helper()
|
|
if testing.Short() {
|
|
t.Skip("requires testcontainers postgres")
|
|
}
|
|
if pgAdmin == nil {
|
|
t.Fatal("postgres unavailable: the container was not started")
|
|
}
|
|
name := "bm_" + strings.ToLower(strings.NewReplacer("/", "_", "-", "_").Replace(t.Name()))
|
|
quoted := `"` + strings.ReplaceAll(name, `"`, `""`) + `"`
|
|
if _, err := pgAdmin.ExecContext(t.Context(), `CREATE DATABASE `+quoted+` TEMPLATE template0 ENCODING 'UTF8'`); err != nil {
|
|
t.Fatalf("create %s: %v", name, err)
|
|
}
|
|
u, err := url.Parse(pgDSN)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
u.Path = "/" + name
|
|
dsn := u.String()
|
|
db, err := sql.Open("pgx", dsn)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() {
|
|
_ = db.Close()
|
|
_, _ = pgAdmin.ExecContext(context.Background(), `DROP DATABASE IF EXISTS `+quoted+` WITH (FORCE)`)
|
|
})
|
|
return db, dsn
|
|
}
|
|
|
|
// migrated activates golem15.user and jz.bm, migrates a fresh database and
|
|
// publishes it on the application, as serve does.
|
|
func migrated(t *testing.T) (*backpack.App, []party.Plugin, *gorm.DB) {
|
|
t.Helper()
|
|
sqlDB, _ := testDatabase(t)
|
|
gdb, err := lagoon.Use(t.Context(), sqlDB)
|
|
if err != nil {
|
|
t.Fatalf("lagoon.Use: %v", err)
|
|
}
|
|
app := backpack.New(testConfig(t))
|
|
plugins := activate(t, app)
|
|
if err := lagoon.Migrate(gdb, plugins); err != nil {
|
|
t.Fatalf("lagoon.Migrate: %v", err)
|
|
}
|
|
if err := lagoon.Publish(app, sqlDB, gdb); err != nil {
|
|
t.Fatalf("lagoon.Publish: %v", err)
|
|
}
|
|
return app, plugins, gdb
|
|
}
|
|
|
|
var bmTables = []string{
|
|
"jz_bm_meetings", "jz_bm_courses", "jz_bm_class_sessions", "jz_bm_deadlines", "jz_bm_readings",
|
|
"jz_bm_reading_progress", "jz_bm_tags", "jz_bm_materials", "jz_bm_material_tag", "jz_bm_calendar_feeds",
|
|
}
|
|
|
|
func TestMigrateUpAndRollback(t *testing.T) {
|
|
_, plugins, gdb := migrated(t)
|
|
m := gdb.Migrator()
|
|
for _, table := range bmTables {
|
|
if !m.HasTable(table) {
|
|
t.Errorf("%s is missing after migrate", table)
|
|
}
|
|
}
|
|
if !m.HasColumn(&models.Course{}, "elective_group") {
|
|
t.Error("jz_bm_courses has no elective_group")
|
|
}
|
|
n := len(bmPlugin(t, plugins).Migrations())
|
|
for i := 0; i < n; i++ {
|
|
if err := lagoon.RollbackLast(gdb, plugins, "jz.bm"); err != nil {
|
|
t.Fatalf("rollback %d: %v", i, err)
|
|
}
|
|
}
|
|
for _, table := range bmTables {
|
|
if m.HasTable(table) {
|
|
t.Errorf("%s is still there after rolling every migration back", table)
|
|
}
|
|
}
|
|
if !m.HasTable("users") {
|
|
t.Error("rolling back jz.bm dropped golem15.user's users table")
|
|
}
|
|
if err := lagoon.Migrate(gdb, plugins); err != nil {
|
|
t.Fatalf("migrate again: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSchemaConstraints(t *testing.T) {
|
|
_, _, gdb := migrated(t)
|
|
exec := func(sql string, args ...any) error { return gdb.Exec(sql, args...).Error }
|
|
if err := exec(`INSERT INTO jz_bm_courses (name, form, semester) VALUES ('A', 'wyklad', 1)`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, bad := range []string{
|
|
`INSERT INTO jz_bm_courses (name, form, semester) VALUES ('A', 'wyklad', 1)`,
|
|
`INSERT INTO jz_bm_courses (name, form, semester) VALUES ('B', 'lecture', 1)`,
|
|
`INSERT INTO jz_bm_meetings (number, starts_on, ends_on) VALUES (1, '2030-10-06', '2030-10-05')`,
|
|
`INSERT INTO jz_bm_class_sessions (course_id, starts_at, ends_at) VALUES (999, NOW(), NOW() + interval '1 hour')`,
|
|
`INSERT INTO jz_bm_deadlines (title, due_at, kind) VALUES ('X', NOW(), 'party')`,
|
|
`INSERT INTO jz_bm_materials (title, type) VALUES ('X', 'video')`,
|
|
} {
|
|
if err := exec(bad); err == nil {
|
|
t.Errorf("accepted: %s", bad)
|
|
}
|
|
}
|
|
}
|
|
|
|
func seedFixture(t *testing.T, plugins []party.Plugin, path string) string {
|
|
t.Helper()
|
|
var out bytes.Buffer
|
|
if err := bonfire.Call(t.Context(), bmPlugin(t, plugins).Commands(), "bm:seed", []string{path}, &out); err != nil {
|
|
t.Fatalf("bm:seed %s: %v\n%s", path, err, out.String())
|
|
}
|
|
return out.String()
|
|
}
|
|
|
|
func count(t *testing.T, gdb *gorm.DB, table string) int64 {
|
|
t.Helper()
|
|
var n int64
|
|
if err := gdb.Table(table).Count(&n).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return n
|
|
}
|
|
|
|
func TestSeedCommandIsIdempotent(t *testing.T) {
|
|
_, plugins, gdb := migrated(t)
|
|
out := seedFixture(t, plugins, "testdata/seed.example.json")
|
|
if !strings.Contains(out, "courses") {
|
|
t.Errorf("summary = %q", out)
|
|
}
|
|
want := map[string]int64{"jz_bm_meetings": 2, "jz_bm_courses": 3, "jz_bm_class_sessions": 3, "jz_bm_deadlines": 2, "jz_bm_readings": 2}
|
|
for table, n := range want {
|
|
if got := count(t, gdb, table); got != n {
|
|
t.Errorf("%s = %d rows, want %d", table, got, n)
|
|
}
|
|
}
|
|
var session models.ClassSession
|
|
if err := gdb.Preload("Course").Preload("Meeting").Where("room = ?", "101").Take(&session).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !session.StartsAt.Equal(time.Date(2030, 10, 5, 6, 0, 0, 0, time.UTC)) || session.Course.Name != "Przedmiot przykładowy A" || session.Meeting.Number != 1 {
|
|
t.Errorf("session = %+v", session)
|
|
}
|
|
var stored time.Time
|
|
if err := gdb.Raw(`SELECT starts_at FROM jz_bm_class_sessions WHERE room = '101'`).Scan(&stored).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !stored.Equal(time.Date(2030, 10, 5, 6, 0, 0, 0, time.UTC)) {
|
|
t.Errorf("stored starts_at = %v, want 06:00 UTC", stored)
|
|
}
|
|
var course models.Course
|
|
if err := gdb.Where("form = ?", "konwersatorium").Take(&course).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if course.ElectiveGroup == nil || *course.ElectiveGroup != "przyklad-do-wyboru" || course.Code != nil {
|
|
t.Errorf("course = %+v", course)
|
|
}
|
|
var ids []uint
|
|
gdb.Model(&models.Course{}).Order("id").Pluck("id", &ids)
|
|
|
|
// A second run changes nothing and keeps the IDs.
|
|
out = seedFixture(t, plugins, "testdata/seed.example.json")
|
|
for _, row := range []string{"meetings\t0\t0\t2", "courses\t0\t0\t3", "class_sessions\t0\t0\t3", "deadlines\t0\t0\t2", "readings\t0\t0\t2"} {
|
|
if !strings.Contains(out, row) {
|
|
t.Errorf("second run summary lacks %q:\n%s", row, out)
|
|
}
|
|
}
|
|
for table, n := range want {
|
|
if got := count(t, gdb, table); got != n {
|
|
t.Errorf("after the second run %s = %d rows, want %d", table, got, n)
|
|
}
|
|
}
|
|
var again []uint
|
|
gdb.Model(&models.Course{}).Order("id").Pluck("id", &again)
|
|
if fmt.Sprint(ids) != fmt.Sprint(again) {
|
|
t.Errorf("course IDs changed: %v -> %v", ids, again)
|
|
}
|
|
|
|
// A changed field updates the row in place.
|
|
dir := t.TempDir()
|
|
changed := filepath.Join(dir, "changed.json")
|
|
writeJSON(t, changed, `{"courses": [{"key": "a", "name": "Przedmiot przykładowy A", "form": "wyklad", "semester": 1, "lecturer": "dr Inna Osoba", "sort_order": 1}],
|
|
"class_sessions": [{"course": "a", "starts_at": "2030-10-05T08:00:00+02:00", "ends_at": "2030-10-05T10:00:00+02:00", "room": "105", "lecturer": "dr Zastępca"}]}`)
|
|
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 ||
|
|
session.Lecturer == nil || *session.Lecturer != "dr Zastępca" {
|
|
t.Errorf("session after update = %+v", session)
|
|
}
|
|
|
|
// An unknown key fails the whole file: the valid meeting is not written.
|
|
invalid := filepath.Join(dir, "invalid.json")
|
|
writeJSON(t, invalid, `{"meetings": [{"number": 3, "starts_on": "2030-11-01", "ends_on": "2030-11-02"}],
|
|
"readings": [{"course": "ghost", "title": "X"}]}`)
|
|
var buf bytes.Buffer
|
|
err := bonfire.Call(t.Context(), bmPlugin(t, plugins).Commands(), "bm:seed", []string{invalid}, &buf)
|
|
if err == nil || !strings.Contains(err.Error(), "ghost") {
|
|
t.Errorf("invalid seed err = %v", err)
|
|
}
|
|
if got := count(t, gdb, "jz_bm_meetings"); got != 2 {
|
|
t.Errorf("an invalid file wrote meetings: %d rows", got)
|
|
}
|
|
|
|
// A database error inside the transaction rolls back the rows before it.
|
|
clash := filepath.Join(dir, "clash.json")
|
|
writeJSON(t, clash, `{"meetings": [{"number": 4, "starts_on": "2030-12-01", "ends_on": "2030-12-02"}],
|
|
"courses": [{"key": "x", "name": "`+strings.Repeat("x", 300)+`", "form": "inne", "semester": 2}]}`)
|
|
if err := bonfire.Call(t.Context(), bmPlugin(t, plugins).Commands(), "bm:seed", []string{clash}, &buf); err == nil {
|
|
t.Error("a name longer than the column was accepted")
|
|
}
|
|
if got := count(t, gdb, "jz_bm_meetings"); got != 2 {
|
|
t.Errorf("a failed seed kept its meetings: %d rows", got)
|
|
}
|
|
}
|
|
|
|
// TestSeedCommandOpensDatabase runs bm:seed the way the binary does:
|
|
// nothing is published on the app, so the command opens database.dsn.
|
|
func TestSeedCommandOpensDatabase(t *testing.T) {
|
|
_, _, gdb := migrated(t)
|
|
var name string
|
|
if err := gdb.Raw("SELECT current_database()").Scan(&name).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
u, err := url.Parse(pgDSN)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
u.Path = "/" + name
|
|
app := backpack.New(testConfig(t))
|
|
plugins := activate(t, app)
|
|
if err := app.Config.Set("database.dsn", u.String()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := app.Config.Set("app.key", base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{7}, 32))); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
seedFixture(t, plugins, "testdata/seed.example.json")
|
|
if got := count(t, gdb, "jz_bm_courses"); got != 3 {
|
|
t.Errorf("courses = %d, want 3", got)
|
|
}
|
|
}
|
|
|
|
func writeJSON(t *testing.T, path, body string) {
|
|
t.Helper()
|
|
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// apiClient drives the assembled application as a signed-in user.
|
|
type apiClient struct {
|
|
t *testing.T
|
|
h http.Handler
|
|
token string
|
|
}
|
|
|
|
func (c apiClient) do(method, path string) (int, map[string]any) {
|
|
c.t.Helper()
|
|
req := httptest.NewRequest(method, path, nil)
|
|
if c.token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+c.token)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
c.h.ServeHTTP(rec, req)
|
|
var body map[string]any
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
c.t.Fatalf("%s %s = %d, not JSON: %s", method, path, rec.Code, rec.Body.String())
|
|
}
|
|
return rec.Code, body
|
|
}
|
|
|
|
func (c apiClient) ok(method, path string) map[string]any {
|
|
c.t.Helper()
|
|
code, body := c.do(method, path)
|
|
if code != http.StatusOK {
|
|
c.t.Fatalf("%s %s = %d %v, want 200", method, path, code, body)
|
|
}
|
|
return body
|
|
}
|
|
|
|
func createUser(t *testing.T, gdb *gorm.DB, email string) uint {
|
|
t.Helper()
|
|
var id uint
|
|
if err := gdb.Raw(`INSERT INTO users (email) VALUES (?) RETURNING id`, email).Scan(&id).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return id
|
|
}
|
|
|
|
func tokenFor(t *testing.T, id uint) string {
|
|
t.Helper()
|
|
token, _, err := bouncer.Mint(testUserSecret, strconv.FormatUint(uint64(id), 10), "http://127.0.0.1/api/login", time.Hour)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return token
|
|
}
|
|
|
|
func list(body map[string]any) []map[string]any {
|
|
raw, _ := body["data"].([]any)
|
|
out := make([]map[string]any, 0, len(raw))
|
|
for _, item := range raw {
|
|
out = append(out, item.(map[string]any))
|
|
}
|
|
return out
|
|
}
|
|
|
|
func obj(v any) map[string]any {
|
|
m, _ := v.(map[string]any)
|
|
return m
|
|
}
|
|
|
|
func id(v any) string { return strconv.Itoa(int(v.(float64))) }
|
|
|
|
func TestAPIAgainstDatabase(t *testing.T) {
|
|
app, plugins, gdb := migrated(t)
|
|
seedFixture(t, plugins, "testdata/seed.example.json")
|
|
h, err := surf.Assemble(app, plugins)
|
|
if err != nil {
|
|
t.Fatalf("Assemble: %v", err)
|
|
}
|
|
alice := apiClient{t: t, h: h, token: tokenFor(t, createUser(t, gdb, "alice@example.test"))}
|
|
bob := apiClient{t: t, h: h, token: tokenFor(t, createUser(t, gdb, "bob@example.test"))}
|
|
guest := apiClient{t: t, h: h}
|
|
|
|
if code, body := guest.do("GET", "/_bm/api/v1/dashboard"); code != http.StatusUnauthorized {
|
|
t.Errorf("guest dashboard = %d %v, want 401", code, body)
|
|
}
|
|
|
|
var courseA, courseAC models.Course
|
|
gdb.Where("form = ?", "wyklad").Take(&courseA)
|
|
gdb.Where("form = ?", "cwiczenia").Take(&courseAC)
|
|
|
|
// Materials: a published note with tags and files, a published link, a
|
|
// draft and one scheduled for the future.
|
|
past := time.Date(2026, 1, 15, 11, 0, 0, 0, time.UTC)
|
|
future := time.Date(2099, 1, 1, 11, 0, 0, 0, time.UTC)
|
|
note := models.Material{CourseID: &courseA.ID, Title: "Notatka", Type: "notatka", Body: ptr("# Nagłówek"), PublishedAt: &past}
|
|
link := models.Material{CourseID: &courseAC.ID, Title: "Link", Type: "link", URL: ptr("https://example.test"), PublishedAt: &past}
|
|
draft := models.Material{CourseID: &courseA.ID, Title: "Szkic", Type: "notatka"}
|
|
later := models.Material{CourseID: &courseA.ID, Title: "Później", Type: "pdf", PublishedAt: &future}
|
|
for _, m := range []*models.Material{¬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())
|
|
}
|
|
}
|