Files
RenewCMS/test/site_articles_acceptance_test.go
T

348 lines
12 KiB
Go

package test
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/internal/domain/article"
"encoding/json"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
)
type articleListBody struct {
Items []struct {
ID uint32 `json:"id"`
Title string `json:"title"`
Online bool `json:"is_online"`
SiteID uint32 `json:"site_id"`
} `json:"items"`
Total int `json:"total"`
Counts struct {
All int `json:"all"`
Online int `json:"online"`
Draft int `json:"draft"`
} `json:"counts"`
}
func decodeArticleList(t *testing.T, rec *httptest.ResponseRecorder) articleListBody {
t.Helper()
var decoded articleListBody
if err := json.Unmarshal(rec.Body.Bytes(), &decoded); err != nil {
t.Fatalf("decode list %q: %v", rec.Body.String(), err)
}
return decoded
}
func (e *siteEnv) seedTitled(id uint32, siteID uint32, title string, online bool) {
e.articles.articles[id] = article.Article{ID: id, Title: title, Body: "Body", IsOnline: online, SiteID: siteID}
}
func TestArticlesBelongToASite(t *testing.T) {
t.Run("A new article starts on the default site", func(t *testing.T) {
e := newSiteEnvWithMain(t)
rec := e.owner(t, http.MethodPost, "/v1/article", `{"title": "Spring release", "body": "Hello"}`)
stored := e.articles.articles[1]
if rec.Code != http.StatusCreated || stored.SiteID != siteMainID || stored.IsOnline {
t.Errorf("status %d, stored %+v", rec.Code, stored)
}
})
t.Run("Article reads carry site_id", func(t *testing.T) {
requests := []struct{ name, method, path, body string }{
{"GET /v1/article/8", http.MethodGet, "/v1/article/8", ""},
{"GET /v1/article/ (each item of items)", http.MethodGet, "/v1/article/", ""},
{"PATCH /v1/article/8/publish", http.MethodPatch, "/v1/article/8/publish", ""},
{"PATCH /v1/article/8", http.MethodPatch, "/v1/article/8", `{"body": "New"}`},
}
for _, request := range requests {
t.Run(request.name, func(t *testing.T) {
e := newSiteEnvWithShop(t)
e.seedTitled(8, siteShopID, "Spring release", false)
rec := e.owner(t, request.method, request.path, request.body)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"site_id":2`) {
t.Errorf("status %d, body %s", rec.Code, rec.Body.String())
}
})
}
t.Run("POST /v1/article", func(t *testing.T) {
e := newSiteEnvWithShop(t)
rec := e.owner(t, http.MethodPost, "/v1/article", `{"title": "Spring release", "body": "Hello"}`)
if rec.Code != http.StatusCreated || !strings.Contains(rec.Body.String(), `"site_id":1`) {
t.Errorf("status %d, body %s", rec.Code, rec.Body.String())
}
})
})
t.Run("The list filters by site key", func(t *testing.T) {
e := newSiteEnvWithShop(t)
e.seedTitled(1, siteShopID, "Shop one", true)
e.seedTitled(2, siteShopID, "Shop two", false)
for id := uint32(3); id <= 5; id++ {
e.seedTitled(id, siteMainID, "Main article", true)
}
list := decodeArticleList(t, e.owner(t, http.MethodGet, "/v1/article/?site=shop", ""))
if list.Total != 2 || len(list.Items) != 2 {
t.Fatalf("list = %+v", list)
}
for _, item := range list.Items {
if item.SiteID != siteShopID {
t.Errorf("item %+v is not on shop", item)
}
}
})
t.Run("The filter composes with the other parameters", func(t *testing.T) {
cases := []struct {
params string
holds func(title string, online bool) bool
min int
}{
{"status=online", func(_ string, online bool) bool { return online }, 1},
{"status=draft", func(_ string, online bool) bool { return !online }, 1},
{"q=spring", func(title string, _ bool) bool { return strings.Contains(strings.ToLower(title), "spring") }, 1},
{"sort=title:asc", func(string, bool) bool { return true }, 2},
{"size=5&page=2", func(string, bool) bool { return true }, 1},
{"status=draft&q=launch", func(title string, online bool) bool {
return !online && strings.Contains(strings.ToLower(title), "launch")
}, 1},
}
for _, c := range cases {
t.Run(c.params, func(t *testing.T) {
e := newSiteEnvWithShop(t)
titles := []string{"Spring launch", "Spring notes", "Launch plan", "Summer", "Autumn", "Winter", "Zeta"}
for index, title := range titles {
e.seedTitled(uint32(index+1), siteShopID, title, index%2 == 0)
}
e.seedTitled(20, siteShopID, "Launch of spring draft", false)
e.seedTitled(30, siteMainID, "Spring launch main draft", false)
e.seedTitled(31, siteMainID, "Main online", true)
list := decodeArticleList(t, e.owner(t, http.MethodGet, "/v1/article/?site=shop&"+c.params, ""))
if len(list.Items) < c.min {
t.Fatalf("expected at least %d items, got %+v", c.min, list)
}
for _, item := range list.Items {
if item.SiteID != siteShopID || !c.holds(item.Title, item.Online) {
t.Errorf("item %+v does not satisfy shop and %s", item, c.params)
}
}
})
}
})
t.Run("Unknown or empty site keys", func(t *testing.T) {
t.Run("nope", func(t *testing.T) {
e := newSiteEnvWithShop(t)
e.seedTitled(1, siteShopID, "Shop one", true)
e.seedTitled(2, siteMainID, "Main draft", false)
global := decodeArticleList(t, e.owner(t, http.MethodGet, "/v1/article/", "")).Counts
rec := e.owner(t, http.MethodGet, "/v1/article/?site=nope", "")
list := decodeArticleList(t, rec)
if rec.Code != http.StatusOK || len(list.Items) != 0 || list.Total != 0 || list.Counts != global {
t.Errorf("status %d, list %+v, global %+v", rec.Code, list, global)
}
})
t.Run("(empty)", func(t *testing.T) {
e := newSiteEnvWithShop(t)
rec := e.owner(t, http.MethodGet, "/v1/article/?site=", "")
expectSiteStatusAndBody(t, rec, http.StatusBadRequest, apperr.BodyErrorMessage)
})
})
t.Run("An anonymous or unverified list is filtered by site and shows online articles only", func(t *testing.T) {
e := newSiteEnvWithShop(t)
e.seedTitled(1, siteShopID, "Shop online", true)
e.seedTitled(2, siteShopID, "Shop offline", false)
for name, rec := range map[string]*httptest.ResponseRecorder{
"anonymous": e.anonymous(t, http.MethodGet, "/v1/article/?site=shop", ""),
"unverified": e.as(t, apiKeyQuinnId, http.MethodGet, "/v1/article/?site=shop", ""),
} {
list := decodeArticleList(t, rec)
if rec.Code != http.StatusOK || len(list.Items) != 1 || list.Items[0].ID != 1 {
t.Errorf("%s: status %d, list %+v", name, rec.Code, list)
}
}
})
t.Run("An offline article is still not found publicly", func(t *testing.T) {
e := newSiteEnvWithShop(t)
e.seedTitled(9, siteShopID, "Hidden", false)
rec := e.anonymous(t, http.MethodGet, "/v1/article/9", "")
expectSiteStatusAndBody(t, rec, http.StatusNotFound, apperr.ArticleNotFoundErrorMessage)
})
t.Run("An article moves to another site", func(t *testing.T) {
e := newSiteEnvWithShop(t)
e.seedTitled(8, siteMainID, "Spring release", true)
rec := e.owner(t, http.MethodPatch, "/v1/article/8", `{"site_id": 2}`)
if rec.Code != http.StatusOK || e.articles.articles[8].SiteID != siteShopID {
t.Errorf("status %d, stored %+v", rec.Code, e.articles.articles[8])
}
})
t.Run("Any role that can edit the article may choose its site", func(t *testing.T) {
credentials := map[string]func(e *siteEnv, t *testing.T) *httptest.ResponseRecorder{
"the owner's jwt cookie": func(e *siteEnv, t *testing.T) *httptest.ResponseRecorder {
return e.as(t, apiKeyOwnerId, http.MethodPatch, "/v1/article/8", `{"site_id": 2}`)
},
"a verified admin's jwt cookie": func(e *siteEnv, t *testing.T) *httptest.ResponseRecorder {
return e.as(t, apiKeyDevId, http.MethodPatch, "/v1/article/8", `{"site_id": 2}`)
},
"a valid API key": func(e *siteEnv, t *testing.T) *httptest.ResponseRecorder {
raw, _ := e.issueKey(apiKeyDevId, "Deploy bot")
return e.asKey(t, raw, http.MethodPatch, "/v1/article/8", `{"site_id": 2}`)
},
}
for name, send := range credentials {
t.Run(name, func(t *testing.T) {
e := newSiteEnvWithShop(t)
e.seedTitled(8, siteMainID, "Spring release", true)
if rec := send(e, t); rec.Code != http.StatusOK || e.articles.articles[8].SiteID != siteShopID {
t.Errorf("status %d: %s", rec.Code, rec.Body.String())
}
})
}
})
t.Run("The body and the site change together", func(t *testing.T) {
e := newSiteEnvWithShop(t)
e.seedTitled(8, siteMainID, "Spring release", true)
rec := e.owner(t, http.MethodPatch, "/v1/article/8", `{"body": "New body", "site_id": 2}`)
stored := e.articles.articles[8]
if rec.Code != http.StatusOK || stored.Body != "New body" || stored.SiteID != siteShopID {
t.Errorf("status %d, stored %+v", rec.Code, stored)
}
})
t.Run("An update without site_id keeps the site", func(t *testing.T) {
e := newSiteEnvWithShop(t)
e.seedTitled(8, siteShopID, "Spring release", true)
rec := e.owner(t, http.MethodPatch, "/v1/article/8", `{"body": "New body"}`)
if rec.Code != http.StatusOK || e.articles.articles[8].SiteID != siteShopID || e.articles.articles[8].Body != "New body" {
t.Errorf("status %d, stored %+v", rec.Code, e.articles.articles[8])
}
})
t.Run("An unusable site_id is refused", func(t *testing.T) {
cases := []struct {
value string
status int
body string
}{
{"999", 404, "site_not_found"},
{"0", 400, ""},
{"-1", 400, apperr.BodyErrorMessage},
{`"a"`, 400, apperr.BodyErrorMessage},
}
for _, c := range cases {
t.Run(c.value, func(t *testing.T) {
e := newSiteEnvWithShop(t)
e.seedTitled(8, siteMainID, "Spring release", true)
before := e.articles.articles[8]
rec := e.owner(t, http.MethodPatch, "/v1/article/8", `{"site_id": `+c.value+`}`)
if rec.Code != c.status || (c.body != "" && bodyText(rec) != c.body) || (c.body == "" && bodyText(rec) == "") {
t.Errorf("status %d body %q", rec.Code, bodyText(rec))
}
if !reflect.DeepEqual(e.articles.articles[8], before) {
t.Errorf("article changed: %+v", e.articles.articles[8])
}
e.noEvent(t)
})
}
})
t.Run("A site cannot be removed from an article", func(t *testing.T) {
e := newSiteEnvWithShop(t)
e.seedTitled(8, siteShopID, "Spring release", true)
rec := e.owner(t, http.MethodPatch, "/v1/article/8", `{"site_id": null}`)
expectSiteStatusAndBody(t, rec, http.StatusBadRequest, apperr.BodyErrorMessage)
if e.articles.articles[8].SiteID != siteShopID {
t.Errorf("article changed")
}
})
t.Run("Moving to the same site records nothing", func(t *testing.T) {
e := newSiteEnvWithShop(t)
e.seedTitled(8, siteShopID, "Spring release", true)
rec := e.owner(t, http.MethodPatch, "/v1/article/8", `{"site_id": 2}`)
if rec.Code != http.StatusOK {
t.Fatalf("status %d", rec.Code)
}
e.noEvent(t)
})
t.Run("An empty or unknown update is refused", func(t *testing.T) {
for _, body := range []string{`{}`, `{"colour": "x"}`, `{"title": "New title"}`, `{"body": null}`} {
t.Run(body, func(t *testing.T) {
e := newSiteEnvWithShop(t)
e.seedTitled(8, siteShopID, "Spring release", true)
before := e.articles.articles[8]
rec := e.owner(t, http.MethodPatch, "/v1/article/8", body)
expectSiteStatusAndBody(t, rec, http.StatusBadRequest, apperr.BodyErrorMessage)
if !reflect.DeepEqual(e.articles.articles[8], before) {
t.Errorf("article changed")
}
e.noEvent(t)
})
}
})
t.Run("is_online is independent of the site", func(t *testing.T) {
e := newSiteEnvWithShop(t)
e.seedTitled(8, siteMainID, "Spring release", true)
e.owner(t, http.MethodPatch, "/v1/article/8", `{"site_id": 2}`)
if !e.articles.articles[8].IsOnline {
t.Errorf("article went offline")
}
})
t.Run("Articles keep working after a site rename", func(t *testing.T) {
e := newSiteEnvWithShop(t)
e.seedTitled(8, siteShopID, "Spring release", true)
e.owner(t, http.MethodPatch, "/v1/site/2", `{"name": "Boutique"}`)
rec := e.owner(t, http.MethodGet, "/v1/article/8", "")
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"site_id":2`) {
t.Errorf("status %d, body %s", rec.Code, rec.Body.String())
}
})
t.Run("A site change is recorded in article.edit with site names", func(t *testing.T) {
e := newSiteEnvWithShop(t)
e.seedTitled(8, siteMainID, "Spring release", true)
e.owner(t, http.MethodPatch, "/v1/article/8", `{"site_id": 2}`)
event := e.single(t)
assertProducerEvent(t, event, "article.edit", "articles", "success")
if got := changesJSON(t, event); got != `[{"field":"site","kind":"text","before":"Main","after":"Shop"}]` {
t.Errorf("changes = %s", got)
}
})
}