From 90649b5b675730daeed7b0de9f3c934068523714 Mon Sep 17 00:00:00 2001 From: iamxvbaba <28732408+iamxvbaba@users.noreply.github.com> Date: Mon, 27 Jul 2026 01:01:30 +0800 Subject: [PATCH] fix(admin): sync moderation review state refresh --- cmd/telesrv-admin/server.go | 22 ++++- cmd/telesrv-admin/session_test.go | 61 +++++++++++++ internal/adminapi/server.go | 39 ++++++-- internal/adminapi/server_test.go | 142 ++++++++++++++++++++++++++++++ 4 files changed, 255 insertions(+), 9 deletions(-) diff --git a/cmd/telesrv-admin/server.go b/cmd/telesrv-admin/server.go index 2fb79186..0185eadc 100644 --- a/cmd/telesrv-admin/server.go +++ b/cmd/telesrv-admin/server.go @@ -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) { + 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) if err != nil { writeAPIError(w, http.StatusInternalServerError, err.Error()) @@ -368,7 +382,7 @@ func (s *server) proxyAdminJSON(w http.ResponseWriter, r *http.Request, apiPath return } 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.Write(raw) } @@ -378,7 +392,7 @@ func (s *server) handleModerationCasesAPI(w http.ResponseWriter, r *http.Request if 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) { @@ -387,7 +401,7 @@ func (s *server) handleModerationCaseAPI(w http.ResponseWriter, r *http.Request) writeAPIError(w, http.StatusBadRequest, "invalid moderation case id") 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) { @@ -396,7 +410,7 @@ func (s *server) handleModerationReportAPI(w http.ResponseWriter, r *http.Reques writeAPIError(w, http.StatusBadRequest, "invalid moderation report id") 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) { diff --git a/cmd/telesrv-admin/session_test.go b/cmd/telesrv-admin/session_test.go index 40c6a3ef..bcebaa9d 100644 --- a/cmd/telesrv-admin/session_test.go +++ b/cmd/telesrv-admin/session_test.go @@ -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) { const maxInt64 = int64(9223372036854775807) raw, err := json.Marshal(StarGiftRow{ diff --git a/internal/adminapi/server.go b/internal/adminapi/server.go index 5a71c06d..bcf1457c 100644 --- a/internal/adminapi/server.go +++ b/internal/adminapi/server.go @@ -727,7 +727,9 @@ func (s *Server) handleModerationCases(w http.ResponseWriter, r *http.Request) { writeModerationError(w, err) 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) { @@ -744,7 +746,7 @@ func (s *Server) handleModerationCase(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusNotFound, "moderation case not found") return } - writeJSON(w, http.StatusOK, detail) + writeJSON(w, http.StatusOK, moderationCaseDetailResponse(detail)) } 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") return } - writeJSON(w, http.StatusOK, report) + writeJSON(w, http.StatusOK, moderationReportResponse(report)) } 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 } 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 } 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 { actions := make([]domain.ModerationActionDraft, 0, len(request.Actions)) for _, action := range request.Actions { diff --git a/internal/adminapi/server_test.go b/internal/adminapi/server_test.go index 20ce76b4..130049ad 100644 --- a/internal/adminapi/server_test.go +++ b/internal/adminapi/server_test.go @@ -3,6 +3,7 @@ package adminapi import ( "bytes" "context" + "encoding/json" "mime/multipart" "net/http" "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) { 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}`))