fix(admin): sync moderation review state refresh
This commit is contained in:
parent
e75fd04a28
commit
90649b5b67
4 changed files with 255 additions and 9 deletions
|
|
@ -346,6 +346,20 @@ func (s *server) handleStarGiftCollectibleAnimationAPI(w http.ResponseWriter, r
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *server) proxyAdminJSON(w http.ResponseWriter, r *http.Request, apiPath string, maxBytes int64) {
|
func (s *server) proxyAdminJSON(w http.ResponseWriter, r *http.Request, apiPath string, maxBytes int64) {
|
||||||
|
s.proxyAdminJSONWithCache(w, r, apiPath, maxBytes, "private, max-age=30")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) proxyAdminJSONNoStore(w http.ResponseWriter, r *http.Request, apiPath string, maxBytes int64) {
|
||||||
|
s.proxyAdminJSONWithCache(w, r, apiPath, maxBytes, "no-store")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) proxyAdminJSONWithCache(
|
||||||
|
w http.ResponseWriter,
|
||||||
|
r *http.Request,
|
||||||
|
apiPath string,
|
||||||
|
maxBytes int64,
|
||||||
|
cacheControl string,
|
||||||
|
) {
|
||||||
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, s.cfg.AdminAPIURL+apiPath, nil)
|
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, s.cfg.AdminAPIURL+apiPath, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
|
@ -368,7 +382,7 @@ func (s *server) proxyAdminJSON(w http.ResponseWriter, r *http.Request, apiPath
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
w.Header().Set("Cache-Control", "private, max-age=30")
|
w.Header().Set("Cache-Control", cacheControl)
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
_, _ = w.Write(raw)
|
_, _ = w.Write(raw)
|
||||||
}
|
}
|
||||||
|
|
@ -378,7 +392,7 @@ func (s *server) handleModerationCasesAPI(w http.ResponseWriter, r *http.Request
|
||||||
if r.URL.RawQuery != "" {
|
if r.URL.RawQuery != "" {
|
||||||
apiPath += "?" + r.URL.RawQuery
|
apiPath += "?" + r.URL.RawQuery
|
||||||
}
|
}
|
||||||
s.proxyAdminJSON(w, r, apiPath, 4<<20)
|
s.proxyAdminJSONNoStore(w, r, apiPath, 4<<20)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *server) handleModerationCaseAPI(w http.ResponseWriter, r *http.Request) {
|
func (s *server) handleModerationCaseAPI(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
@ -387,7 +401,7 @@ func (s *server) handleModerationCaseAPI(w http.ResponseWriter, r *http.Request)
|
||||||
writeAPIError(w, http.StatusBadRequest, "invalid moderation case id")
|
writeAPIError(w, http.StatusBadRequest, "invalid moderation case id")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.proxyAdminJSON(w, r, fmt.Sprintf("/v1/moderation/cases/%d", id), 4<<20)
|
s.proxyAdminJSONNoStore(w, r, fmt.Sprintf("/v1/moderation/cases/%d", id), 4<<20)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *server) handleModerationReportAPI(w http.ResponseWriter, r *http.Request) {
|
func (s *server) handleModerationReportAPI(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
@ -396,7 +410,7 @@ func (s *server) handleModerationReportAPI(w http.ResponseWriter, r *http.Reques
|
||||||
writeAPIError(w, http.StatusBadRequest, "invalid moderation report id")
|
writeAPIError(w, http.StatusBadRequest, "invalid moderation report id")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.proxyAdminJSON(w, r, fmt.Sprintf("/v1/moderation/reports/%d", id), 4<<20)
|
s.proxyAdminJSONNoStore(w, r, fmt.Sprintf("/v1/moderation/reports/%d", id), 4<<20)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *server) handleClaimModerationCaseAPI(w http.ResponseWriter, r *http.Request) {
|
func (s *server) handleClaimModerationCaseAPI(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,67 @@ func TestSetAccountFrozenBFFForwardsClientVisibleState(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestModerationReadAPIDisablesBrowserCaching(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
requestPath string
|
||||||
|
upstreamPath string
|
||||||
|
invoke func(*server, http.ResponseWriter, *http.Request)
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "case list",
|
||||||
|
requestPath: "/api/moderation/cases?status=open",
|
||||||
|
upstreamPath: "/v1/moderation/cases?status=open",
|
||||||
|
invoke: (*server).handleModerationCasesAPI,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "case detail",
|
||||||
|
requestPath: "/api/moderation/cases/7",
|
||||||
|
upstreamPath: "/v1/moderation/cases/7",
|
||||||
|
invoke: func(s *server, w http.ResponseWriter, r *http.Request) {
|
||||||
|
r.SetPathValue("id", "7")
|
||||||
|
s.handleModerationCaseAPI(w, r)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "report detail",
|
||||||
|
requestPath: "/api/moderation/reports/9",
|
||||||
|
upstreamPath: "/v1/moderation/reports/9",
|
||||||
|
invoke: func(s *server, w http.ResponseWriter, r *http.Request) {
|
||||||
|
r.SetPathValue("id", "9")
|
||||||
|
s.handleModerationReportAPI(w, r)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if got := r.URL.RequestURI(); got != test.upstreamPath {
|
||||||
|
t.Fatalf("upstream request URI = %q, want %q", got, test.upstreamPath)
|
||||||
|
}
|
||||||
|
if got := r.Header.Get("Authorization"); got != "Bearer secret" {
|
||||||
|
t.Fatalf("upstream authorization = %q", got)
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{}`))
|
||||||
|
}))
|
||||||
|
defer upstream.Close()
|
||||||
|
|
||||||
|
srv := &server{cfg: uiConfig{AdminAPIURL: upstream.URL, AdminAPIToken: "secret"}}
|
||||||
|
req := httptest.NewRequest(http.MethodGet, test.requestPath, nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
test.invoke(srv, rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if got := rec.Header().Get("Cache-Control"); got != "no-store" {
|
||||||
|
t.Fatalf("Cache-Control = %q, want no-store", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestStarGiftRowJSONPreservesInt64AsDecimalStrings(t *testing.T) {
|
func TestStarGiftRowJSONPreservesInt64AsDecimalStrings(t *testing.T) {
|
||||||
const maxInt64 = int64(9223372036854775807)
|
const maxInt64 = int64(9223372036854775807)
|
||||||
raw, err := json.Marshal(StarGiftRow{
|
raw, err := json.Marshal(StarGiftRow{
|
||||||
|
|
|
||||||
|
|
@ -727,7 +727,9 @@ func (s *Server) handleModerationCases(w http.ResponseWriter, r *http.Request) {
|
||||||
writeModerationError(w, err)
|
writeModerationError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"cases": items})
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"cases": moderationCasesResponse(items),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleModerationCase(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleModerationCase(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
@ -744,7 +746,7 @@ func (s *Server) handleModerationCase(w http.ResponseWriter, r *http.Request) {
|
||||||
writeError(w, http.StatusNotFound, "moderation case not found")
|
writeError(w, http.StatusNotFound, "moderation case not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, detail)
|
writeJSON(w, http.StatusOK, moderationCaseDetailResponse(detail))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleModerationReport(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleModerationReport(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
@ -761,7 +763,7 @@ func (s *Server) handleModerationReport(w http.ResponseWriter, r *http.Request)
|
||||||
writeError(w, http.StatusNotFound, "moderation report not found")
|
writeError(w, http.StatusNotFound, "moderation report not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, report)
|
writeJSON(w, http.StatusOK, moderationReportResponse(report))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleClaimModerationCase(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleClaimModerationCase(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
@ -800,7 +802,7 @@ func (s *Server) handleDecideModerationCase(w http.ResponseWriter, r *http.Reque
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"created": created, "case": detail,
|
"created": created, "case": moderationCaseDetailResponse(detail),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -855,10 +857,37 @@ func (s *Server) handleReviewModerationAppeal(w http.ResponseWriter, r *http.Req
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"created": created, "case": detail,
|
"created": created, "case": moderationCaseDetailResponse(detail),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func moderationCasesResponse(items []domain.ModerationCase) []domain.ModerationCase {
|
||||||
|
if items == nil {
|
||||||
|
return []domain.ModerationCase{}
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
func moderationCaseDetailResponse(detail domain.ModerationCaseDetail) domain.ModerationCaseDetail {
|
||||||
|
if detail.Decisions == nil {
|
||||||
|
detail.Decisions = []domain.ModerationDecision{}
|
||||||
|
}
|
||||||
|
if detail.Actions == nil {
|
||||||
|
detail.Actions = []domain.ModerationAction{}
|
||||||
|
}
|
||||||
|
if detail.Appeals == nil {
|
||||||
|
detail.Appeals = []domain.ModerationAppeal{}
|
||||||
|
}
|
||||||
|
return detail
|
||||||
|
}
|
||||||
|
|
||||||
|
func moderationReportResponse(report domain.ModerationReport) domain.ModerationReport {
|
||||||
|
if report.MediaHolds == nil {
|
||||||
|
report.MediaHolds = []domain.ModerationMediaHold{}
|
||||||
|
}
|
||||||
|
return report
|
||||||
|
}
|
||||||
|
|
||||||
func moderationDecisionDomain(caseID, appealID int64, request moderationDecisionRequest) domain.ModerationDecisionRequest {
|
func moderationDecisionDomain(caseID, appealID int64, request moderationDecisionRequest) domain.ModerationDecisionRequest {
|
||||||
actions := make([]domain.ModerationActionDraft, 0, len(request.Actions))
|
actions := make([]domain.ModerationActionDraft, 0, len(request.Actions))
|
||||||
for _, action := range request.Actions {
|
for _, action := range request.Actions {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package adminapi
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
|
@ -121,6 +122,147 @@ func TestAdminAPIModerationQueueDecisionAndAppealReview(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type emptyModerationCollectionsService struct {
|
||||||
|
fakeService
|
||||||
|
}
|
||||||
|
|
||||||
|
func (emptyModerationCollectionsService) ModerationCases(
|
||||||
|
context.Context,
|
||||||
|
domain.ModerationCaseFilter,
|
||||||
|
) ([]domain.ModerationCase, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (emptyModerationCollectionsService) ModerationCase(
|
||||||
|
_ context.Context,
|
||||||
|
caseID int64,
|
||||||
|
) (domain.ModerationCaseDetail, bool, error) {
|
||||||
|
return domain.ModerationCaseDetail{
|
||||||
|
Case: domain.ModerationCase{ID: caseID},
|
||||||
|
ReportIDs: []int64{9},
|
||||||
|
}, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (emptyModerationCollectionsService) ModerationReport(
|
||||||
|
_ context.Context,
|
||||||
|
reportID int64,
|
||||||
|
) (domain.ModerationReport, bool, error) {
|
||||||
|
return domain.ModerationReport{
|
||||||
|
ID: reportID,
|
||||||
|
Items: []domain.ModerationReportItem{{ItemID: 10}},
|
||||||
|
}, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (emptyModerationCollectionsService) DecideModerationCase(
|
||||||
|
_ context.Context,
|
||||||
|
request domain.ModerationDecisionRequest,
|
||||||
|
) (domain.ModerationCaseDetail, bool, error) {
|
||||||
|
return domain.ModerationCaseDetail{
|
||||||
|
Case: domain.ModerationCase{ID: request.CaseID},
|
||||||
|
ReportIDs: []int64{9},
|
||||||
|
}, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (emptyModerationCollectionsService) ReviewModerationAppeal(
|
||||||
|
_ context.Context,
|
||||||
|
request domain.ModerationDecisionRequest,
|
||||||
|
) (domain.ModerationCaseDetail, bool, error) {
|
||||||
|
return domain.ModerationCaseDetail{
|
||||||
|
Case: domain.ModerationCase{ID: request.CaseID},
|
||||||
|
ReportIDs: []int64{9},
|
||||||
|
}, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminAPIModerationCollectionsAreJSONArrays(t *testing.T) {
|
||||||
|
srv := &Server{token: "secret", svc: emptyModerationCollectionsService{}}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
method string
|
||||||
|
path string
|
||||||
|
body string
|
||||||
|
keys []string
|
||||||
|
nonEmptyKeys []string
|
||||||
|
nested string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty queue", method: http.MethodGet,
|
||||||
|
path: "/v1/moderation/cases", keys: []string{"cases"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "fresh case", method: http.MethodGet,
|
||||||
|
path: "/v1/moderation/cases/7",
|
||||||
|
keys: []string{"Decisions", "Actions", "Appeals"},
|
||||||
|
nonEmptyKeys: []string{"ReportIDs"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "report without media holds", method: http.MethodGet,
|
||||||
|
path: "/v1/moderation/reports/9",
|
||||||
|
keys: []string{"MediaHolds"},
|
||||||
|
nonEmptyKeys: []string{"Items"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "decision response", method: http.MethodPost,
|
||||||
|
path: "/v1/moderation/cases/7/decide", body: `{}`,
|
||||||
|
nested: "case",
|
||||||
|
keys: []string{"Decisions", "Actions", "Appeals"},
|
||||||
|
nonEmptyKeys: []string{"ReportIDs"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "appeal review response", method: http.MethodPost,
|
||||||
|
path: "/v1/moderation/cases/7/appeals/8/review", body: `{}`,
|
||||||
|
nested: "case",
|
||||||
|
keys: []string{"Decisions", "Actions", "Appeals"},
|
||||||
|
nonEmptyKeys: []string{"ReportIDs"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(tt.method, tt.path, strings.NewReader(tt.body))
|
||||||
|
req.Header.Set("Authorization", "Bearer secret")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
srv.routes().ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var response map[string]any
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
if tt.nested != "" {
|
||||||
|
nestedValue := response[tt.nested]
|
||||||
|
var ok bool
|
||||||
|
response, ok = nestedValue.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("%s=%T, want object; body=%s",
|
||||||
|
tt.nested, nestedValue, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, key := range tt.keys {
|
||||||
|
value, ok := response[key]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("%s missing; body=%s", key, rec.Body.String())
|
||||||
|
}
|
||||||
|
items, ok := value.([]any)
|
||||||
|
if !ok || len(items) != 0 {
|
||||||
|
t.Fatalf("%s=%#v, want empty JSON array; body=%s",
|
||||||
|
key, value, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, key := range tt.nonEmptyKeys {
|
||||||
|
value, ok := response[key]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("%s missing; body=%s", key, rec.Body.String())
|
||||||
|
}
|
||||||
|
items, ok := value.([]any)
|
||||||
|
if !ok || len(items) == 0 {
|
||||||
|
t.Fatalf("%s=%#v, want non-empty JSON array; body=%s",
|
||||||
|
key, value, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAdminAPISetVerified(t *testing.T) {
|
func TestAdminAPISetVerified(t *testing.T) {
|
||||||
srv := &Server{token: "secret", svc: fakeService{}}
|
srv := &Server{token: "secret", svc: fakeService{}}
|
||||||
req := httptest.NewRequest(http.MethodPost, "/v1/accounts/set-verified", strings.NewReader(`{"command_id":"c2","actor":"ops","reason":"official","dry_run":true,"user_id":1001,"verified":true}`))
|
req := httptest.NewRequest(http.MethodPost, "/v1/accounts/set-verified", strings.NewReader(`{"command_id":"c2","actor":"ops","reason":"official","dry_run":true,"user_id":1001,"verified":true}`))
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue