feat(manage): GET and PUT /api/v1/bm/materials/{id}
The management API could only upsert a material on its title and course,
so a renamed note became a second material. PUT /materials/{id} applies the
same body to one material (422 when the new title and course belong to
another one), GET /materials/{id} shows a draft or published material with
its public files, and the upsert, update and validation share one path.
The README documents the management API.
This commit is contained in:
29
README.md
29
README.md
@@ -185,6 +185,35 @@ The calendar is RFC 5545 (CRLF lines folded at 75 octets, escaped TEXT) with tim
|
||||
|
||||
`DTSTAMP` and `LAST-MODIFIED` are the row's `updated_at`, so an unchanged timetable gives the same bytes.
|
||||
|
||||
## Management API
|
||||
|
||||
`/api/v1/bm` is what `bm:seed` and the materials screen do, over HTTP, for scripts: it sits behind the application-wide API token of `golem15.user` (`Authorization: Bearer <token>`, middleware `user.api_token`), not the students' `jwt.auth`. Every route needs a token scope (`user.scope:read` or `user.scope:write`) and, except `/me`, a permission of the token owner's groups (`jz.bm.access_courses`, `jz.bm.access_schedule`, `jz.bm.access_materials`, the codes of the admin screens); a missing one is a 403. The group is throttled per token (`throttle:jz.bm.manage`, 120 a minute). Responses are `{"data": …}`; validation errors are a 422 in Laravel's shape (`{"message", "errors": {field: [...]}}`), a bad or oversized body a 400 or 413.
|
||||
|
||||
| Method | Route | Scope, permission | Answer |
|
||||
|--------|-------|-------------------|--------|
|
||||
| GET | `/api/v1/bm/me` | read | The token owner's `user_id` and the token (`id`, `name`, `scopes`, `expires_at`). |
|
||||
| GET | `/api/v1/bm/courses` | read, courses | Every course (`courseJSON`), for the `course_id` of a material. |
|
||||
| GET | `/api/v1/bm/materials` | read, materials | Every material, drafts included, newest first. |
|
||||
| GET | `/api/v1/bm/materials/{id}` | read, materials | One material, draft or not, with its public `files`. 404 when unknown. |
|
||||
| POST | `/api/v1/bm/materials` | write, materials | Upsert on `title` + `course_id`: 201 `{"data", "created": true}` for a new material, 200 `{"data", "created": false}` for an updated one. |
|
||||
| PUT | `/api/v1/bm/materials/{id}` | write, materials | The same body applied to that material, so the title or course can change. 404 when unknown; 422 on `title` when another material already has the new title and course. |
|
||||
| DELETE | `/api/v1/bm/materials/{id}` | write, materials | 204; 404 when unknown. |
|
||||
| POST | `/api/v1/bm/seed?dry_run=&prune=` | write, courses + schedule + materials | `bm:seed` over HTTP: the body is the seed document, the answer the summary table. |
|
||||
|
||||
The material body (POST and PUT), unknown keys are a 422:
|
||||
|
||||
```json
|
||||
{"course_id": 7, "title": "Definicje globalizacji", "type": "notatka",
|
||||
"body": "# Robertson\n\n> …", "source": null, "url": null,
|
||||
"published_at": "2026-10-16T20:00:00+02:00", "tags": ["Globalizacja", "Zjazd 2"]}
|
||||
```
|
||||
|
||||
- `type`: `pdf`, `notatka`, `link`, `inne`. `body` is Markdown, rendered by the student app (headings start at h2 there; the title is the page's h1).
|
||||
- `course_id`: null or an existing course. `body`, `source` and `url` are replaced as sent (absent means null).
|
||||
- `published_at`: absent publishes a new material now and keeps an existing one as it is; `null` makes it a draft; otherwise a time as in the seed file.
|
||||
- `tags`: absent keeps the tags; otherwise the list of names replaces them, and unknown tags are created (slug from the name, Polish letters folded).
|
||||
- Files are not uploaded through this API; attach them in the admin.
|
||||
|
||||
## Student app
|
||||
|
||||
The built single-page app (Vue, source in the application repository's `frontend/`) is embedded from `public/` and served at `/`. Existing files are served as they are: `assets/*` (content-hashed) with `Cache-Control: public, max-age=31536000, immutable`, everything else with `no-cache` and an ETag (304 on revalidation). A GET for any other extension-less path answers `index.html` (client routes); a missing path with an extension is a 404. `/_user`, `/_bm`, `/_quizzes`, `/storage` and the admin path (`backend.uri`, default `/bmadmin`) are never answered by the app. Routes fails when `public/index.html` is missing. The output of `npm run build` in `frontend/` is committed in `public/`.
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
@@ -15,6 +16,7 @@ import (
|
||||
usermodels "git.golem15.com/golem15/sm-user-plugin/models"
|
||||
"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/surf"
|
||||
"git.golem15.com/golem15/summercms/modules/wire"
|
||||
"git.golem15.com/jakub/sm-bm-plugin/classes"
|
||||
@@ -198,7 +200,7 @@ func (h *Handlers) ManagedMaterials(w http.ResponseWriter, r *http.Request) {
|
||||
writeData(w, out)
|
||||
}
|
||||
|
||||
// materialInput is the body of POST /materials.
|
||||
// materialInput is the body of POST /materials and PUT /materials/{id}.
|
||||
type materialInput struct {
|
||||
CourseID *uint `json:"course_id"`
|
||||
Title string `json:"title"`
|
||||
@@ -210,29 +212,36 @@ type materialInput struct {
|
||||
Tags *[]string `json:"tags"`
|
||||
}
|
||||
|
||||
// UpsertMaterial answers POST /materials: it creates the material, or
|
||||
// updates the one with the same title and course, so sending a document
|
||||
// twice changes nothing. body, source and url are replaced as sent.
|
||||
// published_at: absent publishes a new material now and keeps an existing
|
||||
// one as it is; null makes a draft; otherwise a timestamp as in the seed
|
||||
// file. tags: absent keeps them; otherwise the list of tag names replaces
|
||||
// them, and unknown tags are created.
|
||||
func (h *Handlers) UpsertMaterial(w http.ResponseWriter, r *http.Request) {
|
||||
// materialDraft is a decoded and validated material body, ready to apply.
|
||||
type materialDraft struct {
|
||||
in materialInput
|
||||
// hasPublishedAt tells a sent published_at (null included) from an
|
||||
// absent one.
|
||||
hasPublishedAt bool
|
||||
publishedAt *time.Time
|
||||
tags []models.Tag
|
||||
}
|
||||
|
||||
// readMaterial decodes and validates the body of POST /materials and
|
||||
// PUT /materials/{id}. It answers 400, 413, 422 or 500 itself and returns
|
||||
// false then.
|
||||
func (h *Handlers) readMaterial(w http.ResponseWriter, r *http.Request, db *gorm.DB) (materialDraft, bool) {
|
||||
var d materialDraft
|
||||
raw, ok := body(w, r)
|
||||
if !ok {
|
||||
return
|
||||
return d, false
|
||||
}
|
||||
var in materialInput
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&in); err != nil {
|
||||
if err := dec.Decode(&d.in); err != nil {
|
||||
writeInvalid(w, "invalid JSON: "+err.Error())
|
||||
return
|
||||
return d, false
|
||||
}
|
||||
var keys map[string]json.RawMessage
|
||||
_ = json.Unmarshal(raw, &keys)
|
||||
_, hasPublishedAt := keys["published_at"]
|
||||
_, d.hasPublishedAt = keys["published_at"]
|
||||
|
||||
in := &d.in
|
||||
in.Title = strings.TrimSpace(in.Title)
|
||||
errs := map[string][]string{}
|
||||
if in.Title == "" || len(in.Title) > 255 {
|
||||
@@ -247,15 +256,13 @@ func (h *Handlers) UpsertMaterial(w http.ResponseWriter, r *http.Request) {
|
||||
if in.URL != nil && len(*in.URL) > 2048 {
|
||||
errs["url"] = []string{"The url may not be longer than 2048 characters."}
|
||||
}
|
||||
var publishedAt *time.Time
|
||||
if in.PublishedAt != nil {
|
||||
t, err := models.ParseDateTime(*in.PublishedAt)
|
||||
if err != nil {
|
||||
errs["published_at"] = []string{"The published_at is not a valid date and time."}
|
||||
}
|
||||
publishedAt = &t
|
||||
d.publishedAt = &t
|
||||
}
|
||||
var tags []models.Tag
|
||||
if in.Tags != nil {
|
||||
for _, name := range *in.Tags {
|
||||
name = strings.TrimSpace(name)
|
||||
@@ -264,20 +271,16 @@ func (h *Handlers) UpsertMaterial(w http.ResponseWriter, r *http.Request) {
|
||||
errs["tags"] = []string{"Each tag must be a name of 1 to 100 characters."}
|
||||
break
|
||||
}
|
||||
if !slices.ContainsFunc(tags, func(t models.Tag) bool { return t.Slug == slug }) {
|
||||
tags = append(tags, models.Tag{Name: name, Slug: slug})
|
||||
if !slices.ContainsFunc(d.tags, func(t models.Tag) bool { return t.Slug == slug }) {
|
||||
d.tags = append(d.tags, models.Tag{Name: name, Slug: slug})
|
||||
}
|
||||
}
|
||||
}
|
||||
db, ok := h.db(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if in.CourseID != nil {
|
||||
var n int64
|
||||
if err := db.Model(&models.Course{}).Where("id = ?", *in.CourseID).Count(&n).Error; err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
return d, false
|
||||
}
|
||||
if n == 0 {
|
||||
errs["course_id"] = []string{"The selected course_id is invalid."}
|
||||
@@ -285,73 +288,210 @@ func (h *Handlers) UpsertMaterial(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
writeValidation(w, errs)
|
||||
return d, false
|
||||
}
|
||||
return d, true
|
||||
}
|
||||
|
||||
// identity scopes a query to the materials with the draft's title and
|
||||
// course: the identity POST /materials upserts on.
|
||||
func (d materialDraft) identity(q *gorm.DB) *gorm.DB {
|
||||
q = q.Where("title = ?", d.in.Title)
|
||||
if d.in.CourseID != nil {
|
||||
return q.Where("course_id = ?", *d.in.CourseID)
|
||||
}
|
||||
return q.Where("course_id IS NULL")
|
||||
}
|
||||
|
||||
// apply writes the draft onto m inside tx. published_at: absent publishes
|
||||
// a new material now and keeps an existing one as it is; null makes a
|
||||
// draft; otherwise the sent time. tags: absent keeps them; otherwise the
|
||||
// list replaces them, and unknown tags are created.
|
||||
func (d materialDraft) apply(tx *gorm.DB, m *models.Material, created bool, now time.Time) error {
|
||||
in := d.in
|
||||
m.CourseID, m.Title, m.Type = in.CourseID, in.Title, in.Type
|
||||
m.Body, m.Source, m.URL = in.Body, in.Source, in.URL
|
||||
switch {
|
||||
case d.hasPublishedAt:
|
||||
m.PublishedAt = d.publishedAt
|
||||
case created:
|
||||
m.PublishedAt = &now
|
||||
}
|
||||
if err := tx.Omit("Course", "Tags").Save(m).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if in.Tags == nil {
|
||||
return nil
|
||||
}
|
||||
if err := tx.Where("material_id = ?", m.ID).Delete(&models.MaterialTag{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, t := range d.tags {
|
||||
var tag models.Tag
|
||||
res := tx.Where("slug = ?", t.Slug).Limit(1).Find(&tag)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
tag = t
|
||||
if err := tx.Create(&tag).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.Create(&models.MaterialTag{MaterialID: m.ID, TagID: tag.ID}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeManaged answers with the material id, reloaded with its course and
|
||||
// tags, plus any extra top-level keys.
|
||||
func writeManaged(w http.ResponseWriter, db *gorm.DB, id uint, status int, extra map[string]any) {
|
||||
var out models.Material
|
||||
if err := preloadMaterial(db).First(&out, id).Error; err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
payload := map[string]any{"data": newManagedMaterialJSON(out)}
|
||||
for k, v := range extra {
|
||||
payload[k] = v
|
||||
}
|
||||
wire.WriteJSON(w, status, payload)
|
||||
}
|
||||
|
||||
// UpsertMaterial answers POST /materials: it creates the material, or
|
||||
// updates the one with the same title and course, so sending a document
|
||||
// twice changes nothing. body, source and url are replaced as sent; see
|
||||
// materialDraft.apply for published_at and tags. A renamed material is a
|
||||
// new one here: PUT /materials/{id} renames.
|
||||
func (h *Handlers) UpsertMaterial(w http.ResponseWriter, r *http.Request) {
|
||||
db, ok := h.db(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
d, ok := h.readMaterial(w, r, db)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var m models.Material
|
||||
created := false
|
||||
err := lagoon.Transaction(r.Context(), db, func(_ context.Context, tx *gorm.DB) error {
|
||||
q := tx.Where("title = ?", in.Title)
|
||||
if in.CourseID != nil {
|
||||
q = q.Where("course_id = ?", *in.CourseID)
|
||||
} else {
|
||||
q = q.Where("course_id IS NULL")
|
||||
}
|
||||
res := q.Order("id").Limit(1).Find(&m)
|
||||
res := d.identity(tx).Order("id").Limit(1).Find(&m)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
created = res.RowsAffected == 0
|
||||
m.CourseID, m.Title, m.Type = in.CourseID, in.Title, in.Type
|
||||
m.Body, m.Source, m.URL = in.Body, in.Source, in.URL
|
||||
switch {
|
||||
case hasPublishedAt:
|
||||
m.PublishedAt = publishedAt
|
||||
case created:
|
||||
now := h.now()
|
||||
m.PublishedAt = &now
|
||||
}
|
||||
if err := tx.Omit("Course", "Tags").Save(&m).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if in.Tags == nil {
|
||||
return nil
|
||||
}
|
||||
if err := tx.Where("material_id = ?", m.ID).Delete(&models.MaterialTag{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, t := range tags {
|
||||
var tag models.Tag
|
||||
res := tx.Where("slug = ?", t.Slug).Limit(1).Find(&tag)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
tag = t
|
||||
if err := tx.Create(&tag).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.Create(&models.MaterialTag{MaterialID: m.ID, TagID: tag.ID}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return d.apply(tx, &m, created, h.now())
|
||||
})
|
||||
if err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
var out models.Material
|
||||
if err := preloadMaterial(db).First(&out, m.ID).Error; err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
status := http.StatusOK
|
||||
if created {
|
||||
status = http.StatusCreated
|
||||
}
|
||||
wire.WriteJSON(w, status, map[string]any{"data": newManagedMaterialJSON(out), "created": created})
|
||||
writeManaged(w, db, m.ID, status, map[string]any{"created": created})
|
||||
}
|
||||
|
||||
// UpdateMaterial answers PUT /materials/{id}: the same body as POST, applied
|
||||
// to that material, so a title or course can change. A title and course
|
||||
// another material already has is a 422, because POST would then be
|
||||
// ambiguous. An unknown id is a 404.
|
||||
func (h *Handlers) UpdateMaterial(w http.ResponseWriter, r *http.Request) {
|
||||
db, ok := h.db(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, ok := surf.IntParam(r, "id")
|
||||
if !ok {
|
||||
writeNotFound(w)
|
||||
return
|
||||
}
|
||||
d, ok := h.readMaterial(w, r, db)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var m models.Material
|
||||
found, taken := false, false
|
||||
err := lagoon.Transaction(r.Context(), db, func(_ context.Context, tx *gorm.DB) error {
|
||||
res := tx.Where("id = ?", id).Limit(1).Find(&m)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return nil
|
||||
}
|
||||
found = true
|
||||
var n int64
|
||||
if err := d.identity(tx.Model(&models.Material{})).Where("id <> ?", m.ID).Count(&n).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if n > 0 {
|
||||
taken = true
|
||||
return nil
|
||||
}
|
||||
return d.apply(tx, &m, false, h.now())
|
||||
})
|
||||
switch {
|
||||
case err != nil:
|
||||
writeOpaque500(w)
|
||||
case !found:
|
||||
writeNotFound(w)
|
||||
case taken:
|
||||
writeValidation(w, map[string][]string{"title": {"Another material already has this title and course."}})
|
||||
default:
|
||||
writeManaged(w, db, m.ID, http.StatusOK, nil)
|
||||
}
|
||||
}
|
||||
|
||||
// managedMaterialDetailJSON is GET /materials/{id}: the material with its
|
||||
// public files.
|
||||
type managedMaterialDetailJSON struct {
|
||||
managedMaterialJSON
|
||||
Files []fileJSON `json:"files"`
|
||||
}
|
||||
|
||||
// ManagedMaterial answers GET /materials/{id}: one material, draft or not,
|
||||
// with its public files.
|
||||
func (h *Handlers) ManagedMaterial(w http.ResponseWriter, r *http.Request) {
|
||||
db, ok := h.db(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, ok := surf.IntParam(r, "id")
|
||||
if !ok {
|
||||
writeNotFound(w)
|
||||
return
|
||||
}
|
||||
var m models.Material
|
||||
res := preloadMaterial(db).Where("jz_bm_materials.id = ?", id).Limit(1).Find(&m)
|
||||
if res.Error != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
writeNotFound(w)
|
||||
return
|
||||
}
|
||||
var files []attach.File
|
||||
err := db.Where("attachment_type = ? AND attachment_id = ? AND field = ?", m.MorphName(), strconv.FormatUint(uint64(m.ID), 10), models.MaterialFilesField).
|
||||
Order("sort_order, id").
|
||||
Find(&files).Error
|
||||
if err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
out := managedMaterialDetailJSON{managedMaterialJSON: newManagedMaterialJSON(m), Files: make([]fileJSON, 0, len(files))}
|
||||
for i := range files {
|
||||
f := &files[i]
|
||||
if !f.Public() {
|
||||
continue
|
||||
}
|
||||
out.Files = append(out.Files, fileJSON{ID: f.ID, URL: f.URL(), Name: f.FileName, Size: f.FileSize, Mime: f.ContentType})
|
||||
}
|
||||
writeData(w, out)
|
||||
}
|
||||
|
||||
// DeleteMaterial answers DELETE /materials/{id}.
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
package bm_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -63,3 +67,139 @@ RETURNING id`).Scan(&groupID).Error; err != nil {
|
||||
t.Fatalf("request after permission revocation = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestManagementMaterialsUpsertAndUpdate drives the material routes of the
|
||||
// management API with a write token: the upsert identity (title and course),
|
||||
// the update by id with a rename and a draft, the identity conflict, and
|
||||
// the 404s.
|
||||
func TestManagementMaterialsUpsertAndUpdate(t *testing.T) {
|
||||
app, plugins, db := migrated(t)
|
||||
|
||||
var userID uint
|
||||
if err := db.Raw(`
|
||||
INSERT INTO users (email, password, must_change_password)
|
||||
VALUES ('bm-materials-token@example.test', '', false)
|
||||
RETURNING id`).Scan(&userID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var groupID uint
|
||||
if err := db.Raw(`
|
||||
INSERT INTO user_groups (name, code, permissions, created_at, updated_at)
|
||||
VALUES ('BM materials', 'bm-materials', '{"jz.bm.access_materials":1}', NOW(), NOW())
|
||||
RETURNING id`).Scan(&groupID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Exec(`INSERT INTO users_groups (user_id, user_group_id) VALUES (?, ?)`, userID, groupID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secret, token, err := userclasses.MintAPIToken("usr_", userID, "BM notes", []string{"read", "write"}, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(token).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
handler, err := surf.Assemble(app, plugins)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
send := func(method, path string, payload any) (*httptest.ResponseRecorder, map[string]any) {
|
||||
t.Helper()
|
||||
var rd io.Reader
|
||||
if payload != nil {
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rd = bytes.NewReader(raw)
|
||||
}
|
||||
req := httptest.NewRequest(method, path, rd)
|
||||
req.Header.Set("Authorization", "Bearer "+secret)
|
||||
if payload != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
var out map[string]any
|
||||
if rec.Body.Len() > 0 {
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &out)
|
||||
}
|
||||
return rec, out
|
||||
}
|
||||
data := func(out map[string]any) map[string]any {
|
||||
t.Helper()
|
||||
d, _ := out["data"].(map[string]any)
|
||||
if d == nil {
|
||||
t.Fatalf("no data object in %v", out)
|
||||
}
|
||||
return d
|
||||
}
|
||||
idPath := func(id any) string { return "/api/v1/bm/materials/" + strconv.Itoa(int(id.(float64))) }
|
||||
|
||||
note := map[string]any{"title": "Definicje globalizacji", "type": "notatka", "body": "# Robertson\n\n> single place", "tags": []string{"Globalizacja", "Zjazd 2"}}
|
||||
rec, out := send(http.MethodPost, "/api/v1/bm/materials", note)
|
||||
if rec.Code != http.StatusCreated || out["created"] != true {
|
||||
t.Fatalf("create = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
first := data(out)
|
||||
if first["published_at"] == nil {
|
||||
t.Error("a new material without published_at is not published")
|
||||
}
|
||||
if tags, _ := first["tags"].([]any); len(tags) != 2 || tags[1].(map[string]any)["slug"] != "zjazd-2" {
|
||||
t.Errorf("tags = %v", first["tags"])
|
||||
}
|
||||
|
||||
rec, out = send(http.MethodPost, "/api/v1/bm/materials", note)
|
||||
if rec.Code != http.StatusOK || out["created"] != false || data(out)["id"] != first["id"] {
|
||||
t.Fatalf("repeated upsert = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
rec, out = send(http.MethodGet, idPath(first["id"]), nil)
|
||||
if rec.Code != http.StatusOK || data(out)["title"] != "Definicje globalizacji" {
|
||||
t.Fatalf("show = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if files, ok := data(out)["files"].([]any); !ok || len(files) != 0 {
|
||||
t.Errorf("files = %v", data(out)["files"])
|
||||
}
|
||||
|
||||
renamed := map[string]any{"title": "Definicje globalizacji (zjazd 2)", "type": "notatka", "body": "zmienione", "published_at": nil}
|
||||
rec, out = send(http.MethodPut, idPath(first["id"]), renamed)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("update = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
updated := data(out)
|
||||
if updated["title"] != renamed["title"] || updated["body"] != "zmienione" || updated["published_at"] != nil {
|
||||
t.Errorf("updated = %v", updated)
|
||||
}
|
||||
if tags, _ := updated["tags"].([]any); len(tags) != 2 {
|
||||
t.Errorf("an update without tags changed them: %v", updated["tags"])
|
||||
}
|
||||
|
||||
rec, out = send(http.MethodPost, "/api/v1/bm/materials", note)
|
||||
if rec.Code != http.StatusCreated || data(out)["id"] == first["id"] {
|
||||
t.Fatalf("upsert after the rename = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
second := data(out)
|
||||
|
||||
rec, out = send(http.MethodPut, idPath(second["id"]), renamed)
|
||||
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"title"`) {
|
||||
t.Fatalf("update onto a taken identity = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
for _, method := range []string{http.MethodGet, http.MethodPut} {
|
||||
var payload any
|
||||
if method == http.MethodPut {
|
||||
payload = renamed
|
||||
}
|
||||
if rec, _ := send(method, "/api/v1/bm/materials/999999", payload); rec.Code != http.StatusNotFound {
|
||||
t.Errorf("%s of an unknown id = %d %s", method, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
if rec, _ := send(http.MethodDelete, idPath(first["id"]), nil); rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("delete = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec, _ := send(http.MethodGet, idPath(first["id"]), nil); rec.Code != http.StatusNotFound {
|
||||
t.Errorf("show after delete = %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,7 +120,9 @@ func TestRoutes(t *testing.T) {
|
||||
"GET /api/v1/bm/me",
|
||||
"GET /api/v1/bm/courses",
|
||||
"GET /api/v1/bm/materials",
|
||||
"GET /api/v1/bm/materials/{id}",
|
||||
"POST /api/v1/bm/materials",
|
||||
"PUT /api/v1/bm/materials/{id}",
|
||||
"DELETE /api/v1/bm/materials/{id}",
|
||||
"POST /api/v1/bm/seed",
|
||||
}
|
||||
|
||||
@@ -116,7 +116,11 @@ func (p *Plugin) Routes(r pact.Router) error {
|
||||
g.Get("/me", h.TokenInfo, "user.scope:read")
|
||||
g.Get("/courses", h.ManagedCourses, "user.scope:read", "user.permission:"+controllers.PermCourses)
|
||||
g.Get("/materials", h.ManagedMaterials, "user.scope:read", "user.permission:"+controllers.PermMaterials)
|
||||
g.Get("/materials/{id}", h.ManagedMaterial, "user.scope:read", "user.permission:"+controllers.PermMaterials)
|
||||
g.Where("id", "[0-9]+")
|
||||
g.Post("/materials", h.UpsertMaterial, "user.scope:write", "user.permission:"+controllers.PermMaterials)
|
||||
g.Put("/materials/{id}", h.UpdateMaterial, "user.scope:write", "user.permission:"+controllers.PermMaterials)
|
||||
g.Where("id", "[0-9]+")
|
||||
g.Delete("/materials/{id}", h.DeleteMaterial, "user.scope:write", "user.permission:"+controllers.PermMaterials)
|
||||
g.Where("id", "[0-9]+")
|
||||
g.Post("/seed", h.ApplySeed, "user.scope:write",
|
||||
|
||||
Reference in New Issue
Block a user