348 lines
12 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|