Testcontainers Postgres, a fresh database per test, skipped under -short: migrations up and down, schema constraints, bm:seed idempotency and rollback, every API route through the real jwt.auth guard, and admin saves through the admin API.
662 lines
24 KiB
Go
662 lines
24 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 }
|