723 lines
27 KiB
Go
723 lines
27 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",
|
|
}
|
|
|
|
func TestMigrateUpAndRollback(t *testing.T) {
|
|
_, plugins, gdb := migrated(t)
|
|
m := gdb.Migrator()
|
|
for _, table := range bmTables {
|
|
if !m.HasTable(table) {
|
|
t.Errorf("%s is missing after migrate", table)
|
|
}
|
|
}
|
|
if !m.HasColumn(&models.Course{}, "elective_group") {
|
|
t.Error("jz_bm_courses has no elective_group")
|
|
}
|
|
n := len(bmPlugin(t, plugins).Migrations())
|
|
for i := 0; i < n; i++ {
|
|
if err := lagoon.RollbackLast(gdb, plugins, "jz.bm"); err != nil {
|
|
t.Fatalf("rollback %d: %v", i, err)
|
|
}
|
|
}
|
|
for _, table := range bmTables {
|
|
if m.HasTable(table) {
|
|
t.Errorf("%s is still there after rolling every migration back", table)
|
|
}
|
|
}
|
|
if !m.HasTable("users") {
|
|
t.Error("rolling back jz.bm dropped golem15.user's users table")
|
|
}
|
|
if err := lagoon.Migrate(gdb, plugins); err != nil {
|
|
t.Fatalf("migrate again: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSchemaConstraints(t *testing.T) {
|
|
_, _, gdb := migrated(t)
|
|
exec := func(sql string, args ...any) error { return gdb.Exec(sql, args...).Error }
|
|
if err := exec(`INSERT INTO jz_bm_courses (name, form, semester) VALUES ('A', 'wyklad', 1)`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, bad := range []string{
|
|
`INSERT INTO jz_bm_courses (name, form, semester) VALUES ('A', 'wyklad', 1)`,
|
|
`INSERT INTO jz_bm_courses (name, form, semester) VALUES ('B', 'lecture', 1)`,
|
|
`INSERT INTO jz_bm_meetings (number, starts_on, ends_on) VALUES (1, '2030-10-06', '2030-10-05')`,
|
|
`INSERT INTO jz_bm_class_sessions (course_id, starts_at, ends_at) VALUES (999, NOW(), NOW() + interval '1 hour')`,
|
|
`INSERT INTO jz_bm_deadlines (title, due_at, kind) VALUES ('X', NOW(), 'party')`,
|
|
`INSERT INTO jz_bm_materials (title, type) VALUES ('X', 'video')`,
|
|
} {
|
|
if err := exec(bad); err == nil {
|
|
t.Errorf("accepted: %s", bad)
|
|
}
|
|
}
|
|
}
|
|
|
|
func seedFixture(t *testing.T, plugins []party.Plugin, path string) string {
|
|
t.Helper()
|
|
var out bytes.Buffer
|
|
if err := bonfire.Call(t.Context(), bmPlugin(t, plugins).Commands(), "bm:seed", []string{path}, &out); err != nil {
|
|
t.Fatalf("bm:seed %s: %v\n%s", path, err, out.String())
|
|
}
|
|
return out.String()
|
|
}
|
|
|
|
func count(t *testing.T, gdb *gorm.DB, table string) int64 {
|
|
t.Helper()
|
|
var n int64
|
|
if err := gdb.Table(table).Count(&n).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return n
|
|
}
|
|
|
|
func TestSeedCommandIsIdempotent(t *testing.T) {
|
|
_, plugins, gdb := migrated(t)
|
|
out := seedFixture(t, plugins, "testdata/seed.example.json")
|
|
if !strings.Contains(out, "courses") {
|
|
t.Errorf("summary = %q", out)
|
|
}
|
|
want := map[string]int64{"jz_bm_meetings": 2, "jz_bm_courses": 3, "jz_bm_class_sessions": 3, "jz_bm_deadlines": 2, "jz_bm_readings": 2}
|
|
for table, n := range want {
|
|
if got := count(t, gdb, table); got != n {
|
|
t.Errorf("%s = %d rows, want %d", table, got, n)
|
|
}
|
|
}
|
|
var session models.ClassSession
|
|
if err := gdb.Preload("Course").Preload("Meeting").Where("room = ?", "101").Take(&session).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if session.StartsAt != "2030-10-05T08:00:00+02:00" || session.Course.Name != "Przedmiot przykładowy A" || session.Meeting.Number != 1 {
|
|
t.Errorf("session = %+v", session)
|
|
}
|
|
var stored time.Time
|
|
if err := gdb.Raw(`SELECT starts_at FROM jz_bm_class_sessions WHERE room = '101'`).Scan(&stored).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !stored.Equal(time.Date(2030, 10, 5, 6, 0, 0, 0, time.UTC)) {
|
|
t.Errorf("stored starts_at = %v, want 06:00 UTC", stored)
|
|
}
|
|
var course models.Course
|
|
if err := gdb.Where("form = ?", "konwersatorium").Take(&course).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if course.ElectiveGroup == nil || *course.ElectiveGroup != "przyklad-do-wyboru" || course.Code != nil {
|
|
t.Errorf("course = %+v", course)
|
|
}
|
|
var ids []uint
|
|
gdb.Model(&models.Course{}).Order("id").Pluck("id", &ids)
|
|
|
|
// A second run changes nothing and keeps the IDs.
|
|
out = seedFixture(t, plugins, "testdata/seed.example.json")
|
|
for _, row := range []string{"meetings\t0\t0\t2", "courses\t0\t0\t3", "class_sessions\t0\t0\t3", "deadlines\t0\t0\t2", "readings\t0\t0\t2"} {
|
|
if !strings.Contains(out, row) {
|
|
t.Errorf("second run summary lacks %q:\n%s", row, out)
|
|
}
|
|
}
|
|
for table, n := range want {
|
|
if got := count(t, gdb, table); got != n {
|
|
t.Errorf("after the second run %s = %d rows, want %d", table, got, n)
|
|
}
|
|
}
|
|
var again []uint
|
|
gdb.Model(&models.Course{}).Order("id").Pluck("id", &again)
|
|
if fmt.Sprint(ids) != fmt.Sprint(again) {
|
|
t.Errorf("course IDs changed: %v -> %v", ids, again)
|
|
}
|
|
|
|
// A changed field updates the row in place.
|
|
dir := t.TempDir()
|
|
changed := filepath.Join(dir, "changed.json")
|
|
writeJSON(t, changed, `{"courses": [{"key": "a", "name": "Przedmiot przykładowy A", "form": "wyklad", "semester": 1, "lecturer": "dr Inna Osoba", "sort_order": 1}],
|
|
"class_sessions": [{"course": "a", "starts_at": "2030-10-05T08:00:00+02:00", "ends_at": "2030-10-05T10:00:00+02:00", "room": "105"}]}`)
|
|
seedFixture(t, plugins, changed)
|
|
var updated models.Course
|
|
gdb.Where("id = ?", ids[0]).Take(&updated)
|
|
if updated.Lecturer == nil || *updated.Lecturer != "dr Inna Osoba" || count(t, gdb, "jz_bm_courses") != 3 {
|
|
t.Errorf("course after update = %+v", updated)
|
|
}
|
|
gdb.Where("course_id = ?", ids[0]).Take(&session)
|
|
if session.Room == nil || *session.Room != "105" || session.MeetingID != nil || count(t, gdb, "jz_bm_class_sessions") != 3 {
|
|
t.Errorf("session after update = %+v", session)
|
|
}
|
|
|
|
// An unknown key fails the whole file: the valid meeting is not written.
|
|
invalid := filepath.Join(dir, "invalid.json")
|
|
writeJSON(t, invalid, `{"meetings": [{"number": 3, "starts_on": "2030-11-01", "ends_on": "2030-11-02"}],
|
|
"readings": [{"course": "ghost", "title": "X"}]}`)
|
|
var buf bytes.Buffer
|
|
err := bonfire.Call(t.Context(), bmPlugin(t, plugins).Commands(), "bm:seed", []string{invalid}, &buf)
|
|
if err == nil || !strings.Contains(err.Error(), "ghost") {
|
|
t.Errorf("invalid seed err = %v", err)
|
|
}
|
|
if got := count(t, gdb, "jz_bm_meetings"); got != 2 {
|
|
t.Errorf("an invalid file wrote meetings: %d rows", got)
|
|
}
|
|
|
|
// A database error inside the transaction rolls back the rows before it.
|
|
clash := filepath.Join(dir, "clash.json")
|
|
writeJSON(t, clash, `{"meetings": [{"number": 4, "starts_on": "2030-12-01", "ends_on": "2030-12-02"}],
|
|
"courses": [{"key": "x", "name": "`+strings.Repeat("x", 300)+`", "form": "inne", "semester": 2}]}`)
|
|
if err := bonfire.Call(t.Context(), bmPlugin(t, plugins).Commands(), "bm:seed", []string{clash}, &buf); err == nil {
|
|
t.Error("a name longer than the column was accepted")
|
|
}
|
|
if got := count(t, gdb, "jz_bm_meetings"); got != 2 {
|
|
t.Errorf("a failed seed kept its meetings: %d rows", got)
|
|
}
|
|
}
|
|
|
|
// TestSeedCommandOpensDatabase runs bm:seed the way the binary does:
|
|
// nothing is published on the app, so the command opens database.dsn.
|
|
func TestSeedCommandOpensDatabase(t *testing.T) {
|
|
_, _, gdb := migrated(t)
|
|
var name string
|
|
if err := gdb.Raw("SELECT current_database()").Scan(&name).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
u, err := url.Parse(pgDSN)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
u.Path = "/" + name
|
|
app := backpack.New(testConfig(t))
|
|
plugins := activate(t, app)
|
|
if err := app.Config.Set("database.dsn", u.String()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := app.Config.Set("app.key", base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{7}, 32))); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
seedFixture(t, plugins, "testdata/seed.example.json")
|
|
if got := count(t, gdb, "jz_bm_courses"); got != 3 {
|
|
t.Errorf("courses = %d, want 3", got)
|
|
}
|
|
}
|
|
|
|
func writeJSON(t *testing.T, path, body string) {
|
|
t.Helper()
|
|
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// apiClient drives the assembled application as a signed-in user.
|
|
type apiClient struct {
|
|
t *testing.T
|
|
h http.Handler
|
|
token string
|
|
}
|
|
|
|
func (c apiClient) do(method, path string) (int, map[string]any) {
|
|
c.t.Helper()
|
|
req := httptest.NewRequest(method, path, nil)
|
|
if c.token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+c.token)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
c.h.ServeHTTP(rec, req)
|
|
var body map[string]any
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
c.t.Fatalf("%s %s = %d, not JSON: %s", method, path, rec.Code, rec.Body.String())
|
|
}
|
|
return rec.Code, body
|
|
}
|
|
|
|
func (c apiClient) ok(method, path string) map[string]any {
|
|
c.t.Helper()
|
|
code, body := c.do(method, path)
|
|
if code != http.StatusOK {
|
|
c.t.Fatalf("%s %s = %d %v, want 200", method, path, code, body)
|
|
}
|
|
return body
|
|
}
|
|
|
|
func createUser(t *testing.T, gdb *gorm.DB, email string) uint {
|
|
t.Helper()
|
|
var id uint
|
|
if err := gdb.Raw(`INSERT INTO users (email) VALUES (?) RETURNING id`, email).Scan(&id).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return id
|
|
}
|
|
|
|
func tokenFor(t *testing.T, id uint) string {
|
|
t.Helper()
|
|
token, _, err := bouncer.Mint(testUserSecret, strconv.FormatUint(uint64(id), 10), "http://127.0.0.1/api/login", time.Hour)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return token
|
|
}
|
|
|
|
func list(body map[string]any) []map[string]any {
|
|
raw, _ := body["data"].([]any)
|
|
out := make([]map[string]any, 0, len(raw))
|
|
for _, item := range raw {
|
|
out = append(out, item.(map[string]any))
|
|
}
|
|
return out
|
|
}
|
|
|
|
func obj(v any) map[string]any {
|
|
m, _ := v.(map[string]any)
|
|
return m
|
|
}
|
|
|
|
func id(v any) string { return strconv.Itoa(int(v.(float64))) }
|
|
|
|
func TestAPIAgainstDatabase(t *testing.T) {
|
|
app, plugins, gdb := migrated(t)
|
|
seedFixture(t, plugins, "testdata/seed.example.json")
|
|
h, err := surf.Assemble(app, plugins)
|
|
if err != nil {
|
|
t.Fatalf("Assemble: %v", err)
|
|
}
|
|
alice := apiClient{t: t, h: h, token: tokenFor(t, createUser(t, gdb, "alice@example.test"))}
|
|
bob := apiClient{t: t, h: h, token: tokenFor(t, createUser(t, gdb, "bob@example.test"))}
|
|
guest := apiClient{t: t, h: h}
|
|
|
|
if code, body := guest.do("GET", "/_bm/api/v1/dashboard"); code != http.StatusUnauthorized {
|
|
t.Errorf("guest dashboard = %d %v, want 401", code, body)
|
|
}
|
|
|
|
var courseA, courseAC models.Course
|
|
gdb.Where("form = ?", "wyklad").Take(&courseA)
|
|
gdb.Where("form = ?", "cwiczenia").Take(&courseAC)
|
|
|
|
// Materials: a published note with tags and files, a published link, a
|
|
// draft and one scheduled for the future.
|
|
past := models.DateTime("2026-01-15 12:00")
|
|
future := models.DateTime("2099-01-01 12:00")
|
|
note := models.Material{CourseID: &courseA.ID, Title: "Notatka", Type: "notatka", Body: ptr("# Nagłówek"), PublishedAt: &past}
|
|
link := models.Material{CourseID: &courseAC.ID, Title: "Link", Type: "link", URL: ptr("https://example.test"), PublishedAt: &past}
|
|
draft := models.Material{CourseID: &courseA.ID, Title: "Szkic", Type: "notatka"}
|
|
later := models.Material{CourseID: &courseA.ID, Title: "Później", Type: "pdf", PublishedAt: &future}
|
|
for _, m := range []*models.Material{¬e, &link, &draft, &later} {
|
|
if err := gdb.Create(m).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
geo := models.Tag{Name: "Geopolityka", Slug: "geopolityka"}
|
|
alpha := models.Tag{Name: "Alfa", Slug: "alfa"}
|
|
gdb.Create(&geo)
|
|
gdb.Create(&alpha)
|
|
gdb.Create(&[]models.MaterialTag{{MaterialID: note.ID, TagID: geo.ID}, {MaterialID: note.ID, TagID: alpha.ID}, {MaterialID: draft.ID, TagID: geo.ID}})
|
|
private := false
|
|
for i, f := range []attach.File{
|
|
{DiskName: "aaaaaaaaaaaaaaaaaaaaaaaa.pdf", FileName: "wyklad.pdf", FileSize: 1234, ContentType: "application/pdf"},
|
|
{DiskName: "bbbbbbbbbbbbbbbbbbbbbbbb.pdf", FileName: "prywatny.pdf", FileSize: 99, ContentType: "application/pdf", IsPublic: &private},
|
|
} {
|
|
f.Field = models.MaterialFilesField
|
|
f.AttachmentType = models.Material{}.MorphName()
|
|
f.AttachmentID = strconv.FormatUint(uint64(note.ID), 10)
|
|
f.SortOrder = i
|
|
if err := gdb.Create(&f).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// Dashboard: the fixture lies in 2030, so everything is upcoming.
|
|
dash := obj(alice.ok("GET", "/_bm/api/v1/dashboard")["data"])
|
|
sessions, _ := dash["next_sessions"].([]any)
|
|
if len(sessions) != 3 {
|
|
t.Fatalf("next_sessions = %v", dash["next_sessions"])
|
|
}
|
|
first := obj(sessions[0])
|
|
if first["starts_at"] != "2030-10-05T08:00:00+02:00" || first["ends_at"] != "2030-10-05T09:30:00+02:00" ||
|
|
obj(first["course"])["name"] != "Przedmiot przykładowy A" || first["meeting_number"] != float64(1) || first["room"] != "101" {
|
|
t.Errorf("first session = %v", first)
|
|
}
|
|
if deadlines, _ := dash["upcoming_deadlines"].([]any); len(deadlines) != 2 || obj(deadlines[1])["due_at"] != "2030-11-30T23:59:00+01:00" {
|
|
t.Errorf("upcoming_deadlines = %v", dash["upcoming_deadlines"])
|
|
}
|
|
if dash["current_meeting"] != nil || obj(dash["next_meeting"])["number"] != float64(1) || obj(dash["next_meeting"])["starts_on"] != "2030-10-05" {
|
|
t.Errorf("meetings = %v / %v", dash["current_meeting"], dash["next_meeting"])
|
|
}
|
|
if r := obj(dash["readings"]); r["total"] != float64(2) || r["read"] != float64(0) || r["required_total"] != float64(1) || r["required_read"] != float64(0) {
|
|
t.Errorf("readings = %v", r)
|
|
}
|
|
|
|
// Reading progress is per user and idempotent.
|
|
var required models.Reading
|
|
gdb.Where("is_required").Take(&required)
|
|
rid := strconv.FormatUint(uint64(required.ID), 10)
|
|
marked := obj(alice.ok("PUT", "/_bm/api/v1/readings/"+rid+"/progress")["data"])
|
|
readAt, _ := marked["read_at"].(string)
|
|
if marked["reading_id"] != float64(required.ID) || !strings.HasSuffix(readAt, "+01:00") && !strings.HasSuffix(readAt, "+02:00") {
|
|
t.Errorf("mark = %v", marked)
|
|
}
|
|
if again := obj(alice.ok("PUT", "/_bm/api/v1/readings/"+rid+"/progress")["data"]); again["read_at"] != readAt {
|
|
t.Errorf("marking again changed read_at: %v -> %v", readAt, again["read_at"])
|
|
}
|
|
if r := obj(obj(alice.ok("GET", "/_bm/api/v1/dashboard")["data"])["readings"]); r["read"] != float64(1) || r["required_read"] != float64(1) {
|
|
t.Errorf("alice readings = %v", r)
|
|
}
|
|
if r := obj(obj(bob.ok("GET", "/_bm/api/v1/dashboard")["data"])["readings"]); r["read"] != float64(0) {
|
|
t.Errorf("bob sees alice's progress: %v", r)
|
|
}
|
|
for _, path := range []string{"/_bm/api/v1/readings/99999/progress"} {
|
|
if code, _ := alice.do("PUT", path); code != http.StatusNotFound {
|
|
t.Errorf("PUT %s = %d, want 404", path, code)
|
|
}
|
|
if code, _ := alice.do("DELETE", path); code != http.StatusNotFound {
|
|
t.Errorf("DELETE %s = %d, want 404", path, code)
|
|
}
|
|
}
|
|
|
|
// Readings list with the caller's state.
|
|
readings := list(alice.ok("GET", "/_bm/api/v1/readings?course="+strconv.FormatUint(uint64(courseA.ID), 10)))
|
|
if len(readings) != 2 || readings[0]["title"] != "Lektura przykładowa 1" || readings[0]["read_at"] != readAt || readings[1]["read_at"] != nil {
|
|
t.Errorf("alice readings = %v", readings)
|
|
}
|
|
if obj(readings[0]["course"])["form"] != "wyklad" || readings[0]["author"] != "A. Autor" || readings[0]["is_required"] != true {
|
|
t.Errorf("reading = %v", readings[0])
|
|
}
|
|
if bobs := list(bob.ok("GET", "/_bm/api/v1/readings")); len(bobs) != 2 || bobs[0]["read_at"] != nil {
|
|
t.Errorf("bob readings = %v", bobs)
|
|
}
|
|
if none := list(alice.ok("GET", "/_bm/api/v1/readings?course=99999")); len(none) != 0 {
|
|
t.Errorf("unknown course readings = %v", none)
|
|
}
|
|
|
|
// Courses with counters.
|
|
courses := list(alice.ok("GET", "/_bm/api/v1/courses"))
|
|
if len(courses) != 3 || courses[0]["name"] != "Przedmiot przykładowy A" || courses[0]["form"] != "wyklad" {
|
|
t.Fatalf("courses = %v", courses)
|
|
}
|
|
if c := obj(courses[0]["counts"]); c["sessions"] != float64(1) || c["readings"] != float64(2) || c["readings_read"] != float64(1) || c["materials"] != float64(1) {
|
|
t.Errorf("course A counts = %v", c)
|
|
}
|
|
if courses[0]["ects"] != float64(4) || courses[1]["ects"] != 2.5 || courses[2]["elective_group"] != "przyklad-do-wyboru" || courses[0]["elective_group"] != nil {
|
|
t.Errorf("course fields = %v", courses)
|
|
}
|
|
if two := list(alice.ok("GET", "/_bm/api/v1/courses?semester=2")); len(two) != 0 {
|
|
t.Errorf("semester 2 = %v", two)
|
|
}
|
|
detail := obj(alice.ok("GET", "/_bm/api/v1/courses/"+strconv.FormatUint(uint64(courseA.ID), 10))["data"])
|
|
if s, _ := detail["sessions"].([]any); len(s) != 1 {
|
|
t.Errorf("detail sessions = %v", detail["sessions"])
|
|
}
|
|
if r, _ := detail["readings"].([]any); len(r) != 2 || obj(r[0])["read_at"] != readAt {
|
|
t.Errorf("detail readings = %v", detail["readings"])
|
|
}
|
|
if m, _ := detail["materials"].([]any); len(m) != 1 || obj(m[0])["title"] != "Notatka" || obj(m[0])["files_count"] != float64(1) {
|
|
t.Errorf("detail materials = %v", detail["materials"])
|
|
}
|
|
if code, _ := alice.do("GET", "/_bm/api/v1/courses/99999"); code != http.StatusNotFound {
|
|
t.Errorf("missing course = %d, want 404", code)
|
|
}
|
|
|
|
// Schedule, meetings and deadlines.
|
|
if day := list(alice.ok("GET", "/_bm/api/v1/schedule?from=2030-10-05&to=2030-10-05")); len(day) != 2 {
|
|
t.Errorf("schedule of 5 Oct = %v", day)
|
|
}
|
|
if rest := list(alice.ok("GET", "/_bm/api/v1/schedule?from=2030-10-06")); len(rest) != 1 || rest[0]["is_online"] != true {
|
|
t.Errorf("schedule from 6 Oct = %v", rest)
|
|
}
|
|
if all := list(alice.ok("GET", "/_bm/api/v1/schedule")); len(all) != 3 {
|
|
t.Errorf("schedule from today = %v", all)
|
|
}
|
|
if none := list(alice.ok("GET", "/_bm/api/v1/schedule?to=2026-01-01")); len(none) != 0 {
|
|
t.Errorf("schedule up to a past day = %v", none)
|
|
}
|
|
meetings := list(alice.ok("GET", "/_bm/api/v1/meetings"))
|
|
if len(meetings) != 2 || meetings[0]["sessions_count"] != float64(2) || meetings[0]["notes"] != "przykład" || meetings[1]["ends_on"] != "2030-10-20" {
|
|
t.Errorf("meetings = %v", meetings)
|
|
}
|
|
if d := list(alice.ok("GET", "/_bm/api/v1/deadlines")); len(d) != 2 || d[0]["kind"] != "organizacyjne" || d[0]["course"] != nil {
|
|
t.Errorf("deadlines = %v", d)
|
|
}
|
|
if d := list(alice.ok("GET", "/_bm/api/v1/deadlines?from=2030-11-01")); len(d) != 1 || obj(d[0]["course"])["form"] != "cwiczenia" {
|
|
t.Errorf("deadlines from November = %v", d)
|
|
}
|
|
|
|
// Materials: published only, filtered, paginated.
|
|
page := alice.ok("GET", "/_bm/api/v1/materials")
|
|
items := list(page)
|
|
if len(items) != 2 || obj(page["meta"])["total"] != float64(2) || obj(page["meta"])["per_page"] != float64(20) {
|
|
t.Fatalf("materials = %v", page)
|
|
}
|
|
for _, f := range []struct{ query, title string }{
|
|
{"?tag=geopolityka", "Notatka"},
|
|
{"?type=link", "Link"},
|
|
{"?course=" + strconv.FormatUint(uint64(courseAC.ID), 10), "Link"},
|
|
} {
|
|
got := list(alice.ok("GET", "/_bm/api/v1/materials"+f.query))
|
|
if len(got) != 1 || got[0]["title"] != f.title {
|
|
t.Errorf("materials%s = %v, want %s", f.query, got, f.title)
|
|
}
|
|
}
|
|
if got := list(alice.ok("GET", "/_bm/api/v1/materials?tag=nieznany")); len(got) != 0 {
|
|
t.Errorf("unknown tag = %v", got)
|
|
}
|
|
paged := alice.ok("GET", "/_bm/api/v1/materials?per_page=1&page=2")
|
|
if got := list(paged); len(got) != 1 || obj(paged["meta"])["last_page"] != float64(2) || obj(paged["meta"])["current_page"] != float64(2) {
|
|
t.Errorf("page 2 of 2 = %v", paged)
|
|
}
|
|
shown := obj(alice.ok("GET", "/_bm/api/v1/materials/"+strconv.FormatUint(uint64(note.ID), 10))["data"])
|
|
files, _ := shown["files"].([]any)
|
|
if shown["body"] != "# Nagłówek" || len(files) != 1 || shown["files_count"] != float64(1) || shown["published_at"] != "2026-01-15T12:00:00+01:00" {
|
|
t.Errorf("material = %v", shown)
|
|
}
|
|
if f := obj(files[0]); f["name"] != "wyklad.pdf" || f["size"] != float64(1234) || f["mime"] != "application/pdf" ||
|
|
!strings.HasPrefix(f["url"].(string), "/storage/uploads/aaa/aaa/aaa/") {
|
|
t.Errorf("file = %v", f)
|
|
}
|
|
if tags, _ := shown["tags"].([]any); len(tags) != 2 || obj(tags[0])["slug"] != "alfa" {
|
|
t.Errorf("tags = %v", shown["tags"])
|
|
}
|
|
for _, hidden := range []uint{draft.ID, later.ID, 99999} {
|
|
if code, _ := alice.do("GET", "/_bm/api/v1/materials/"+strconv.FormatUint(uint64(hidden), 10)); code != http.StatusNotFound {
|
|
t.Errorf("material %d = %d, want 404", hidden, code)
|
|
}
|
|
}
|
|
|
|
// Unmarking clears the state; unmarking again is fine.
|
|
if cleared := obj(alice.ok("DELETE", "/_bm/api/v1/readings/"+rid+"/progress")["data"]); cleared["read_at"] != nil {
|
|
t.Errorf("unmark = %v", cleared)
|
|
}
|
|
alice.ok("DELETE", "/_bm/api/v1/readings/"+rid+"/progress")
|
|
if count(t, gdb, "jz_bm_reading_progress") != 0 {
|
|
t.Error("progress rows remain after unmarking")
|
|
}
|
|
|
|
// Bad parameters answer 422 through the whole stack.
|
|
code, body := alice.do("GET", "/_bm/api/v1/schedule?from=2030-10-10&to=2030-10-01")
|
|
if code != http.StatusUnprocessableEntity || obj(body["errors"])["to"] == nil {
|
|
t.Errorf("bad range = %d %v", code, body)
|
|
}
|
|
if msg, _ := body["message"].(string); msg == "" || strings.HasPrefix(msg, "validation.") {
|
|
t.Errorf("422 message %q is not translated", msg)
|
|
}
|
|
|
|
// Deleting a course cascades to its sessions and readings, and unlinks
|
|
// its materials.
|
|
gdb.Exec(`DELETE FROM jz_bm_courses WHERE id = ?`, courseA.ID)
|
|
if n := count(t, gdb, "jz_bm_readings"); n != 0 {
|
|
t.Errorf("readings after deleting the course = %d", n)
|
|
}
|
|
var orphan models.Material
|
|
gdb.Where("id = ?", note.ID).Take(&orphan)
|
|
if orphan.CourseID != nil {
|
|
t.Errorf("material kept course_id %v", *orphan.CourseID)
|
|
}
|
|
}
|
|
|
|
func ptr(s string) *string { return &s }
|
|
|
|
// TestSeedPrune moves a session, drops a course, renames a course's form and
|
|
// drops a reading: --dry-run reports it without writing, --prune applies it.
|
|
func TestSeedPrune(t *testing.T) {
|
|
_, plugins, gdb := migrated(t)
|
|
seedFixture(t, plugins, "testdata/seed.example.json")
|
|
next := filepath.Join(t.TempDir(), "next.json")
|
|
writeJSON(t, next, `{"meetings": [{"number": 1, "starts_on": "2030-10-05", "ends_on": "2030-10-06", "notes": "przykład"}],
|
|
"courses": [{"key": "a-w", "name": "Przedmiot przykładowy A", "form": "wyklad", "semester": 1, "sort_order": 1},
|
|
{"key": "b", "name": "Przedmiot przykładowy B", "form": "inne", "semester": 1, "sort_order": 3}],
|
|
"class_sessions": [{"course": "a-w", "meeting": 1, "starts_at": "2030-10-05T08:15:00+02:00", "ends_at": "2030-10-05T09:45:00+02:00"},
|
|
{"course": "b", "meeting": 1, "starts_at": "2030-10-06T08:00:00+02:00", "ends_at": "2030-10-06T09:30:00+02:00"}],
|
|
"readings": [{"course": "a-w", "title": "Lektura przykładowa 1", "is_required": true, "sort_order": 1}]}`)
|
|
before := map[string]int64{}
|
|
for _, table := range []string{"jz_bm_courses", "jz_bm_class_sessions", "jz_bm_readings", "jz_bm_meetings", "jz_bm_deadlines"} {
|
|
before[table] = count(t, gdb, table)
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
if err := bonfire.Call(t.Context(), bmPlugin(t, plugins).Commands(), "bm:seed", []string{next, "--prune", "--dry-run"}, &buf); err != nil {
|
|
t.Fatalf("dry run: %v\n%s", err, buf.String())
|
|
}
|
|
out := buf.String()
|
|
for _, want := range []string{"courses\t1\t1\t0\t2", "class_sessions\t2\t0\t0\t3", "readings\t0\t1\t0\t1", "nothing was written", `deleted: course "Przedmiot przykładowy A" (cwiczenia, semester 1) with 1 sessions and 0 readings`} {
|
|
if !strings.Contains(out, want) {
|
|
t.Errorf("dry run output lacks %q:\n%s", want, out)
|
|
}
|
|
}
|
|
for table, n := range before {
|
|
if got := count(t, gdb, table); got != n {
|
|
t.Errorf("dry run changed %s: %d -> %d", table, n, got)
|
|
}
|
|
}
|
|
|
|
buf.Reset()
|
|
if err := bonfire.Call(t.Context(), bmPlugin(t, plugins).Commands(), "bm:seed", []string{next, "--prune"}, &buf); err != nil {
|
|
t.Fatalf("prune: %v\n%s", err, buf.String())
|
|
}
|
|
want := map[string]int64{"jz_bm_courses": 2, "jz_bm_class_sessions": 2, "jz_bm_readings": 1, "jz_bm_meetings": 2, "jz_bm_deadlines": 2}
|
|
for table, n := range want {
|
|
if got := count(t, gdb, table); got != n {
|
|
t.Errorf("after prune %s = %d rows, want %d", table, got, n)
|
|
}
|
|
}
|
|
var orphan int64
|
|
gdb.Model(&models.Deadline{}).Where("title = ? AND course_id IS NULL", "Esej przykładowy").Count(&orphan)
|
|
if orphan != 1 {
|
|
t.Error("the deadline of the pruned course was not unlinked")
|
|
}
|
|
|
|
// Without --prune the same file deletes nothing.
|
|
seedFixture(t, plugins, "testdata/seed.example.json")
|
|
buf.Reset()
|
|
if err := bonfire.Call(t.Context(), bmPlugin(t, plugins).Commands(), "bm:seed", []string{next}, &buf); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// The fixture brought back course a-c (its session) and b-k next to b.
|
|
if got := count(t, gdb, "jz_bm_courses"); got != 4 || strings.Contains(buf.String(), "deleted:") {
|
|
t.Errorf("seed without --prune deleted rows: %d courses\n%s", got, buf.String())
|
|
}
|
|
}
|