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) } }) }