feat: expose authenticated growth report API
This commit is contained in:
129
internal/api/growth_test.go
Normal file
129
internal/api/growth_test.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/growthreport"
|
||||
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/identity"
|
||||
)
|
||||
|
||||
func TestValidIdempotencyKeyUsesPrintableAllowlist(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name, key string
|
||||
want bool
|
||||
}{{"valid", "growth-123456", true}, {"short", "short", false}, {"space", "growth key", false}, {"unicode", "رشد-گزارش", false}}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := validIdempotencyKey(tt.key); got != tt.want {
|
||||
t.Fatalf("validIdempotencyKey(%q)=%v", tt.key, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestDecodeStrictJSONRejectsTrailingAndUnknownFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, body := range []string{`{"refresh":true}{}`, `{"unexpected":true}`} {
|
||||
request := httptest.NewRequest("POST", "/", strings.NewReader(body))
|
||||
recorder := httptest.NewRecorder()
|
||||
var value struct {
|
||||
Refresh bool `json:"refresh"`
|
||||
}
|
||||
if err := decodeStrictJSON(recorder, request, &value); err == nil {
|
||||
t.Fatalf("decodeStrictJSON(%q) error=nil", body)
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestGrowthReportIDPatternRejectsTenantEnumerationInputs(t *testing.T) {
|
||||
t.Parallel()
|
||||
if !growthReportIDPattern.MatchString("growth_0123456789abcdef01234567") {
|
||||
t.Fatal("valid report id rejected")
|
||||
}
|
||||
for _, id := range []string{"growth_1", "../growth_0123456789abcdef01234567", "run_0123456789abcdef01234567"} {
|
||||
if growthReportIDPattern.MatchString(id) {
|
||||
t.Fatalf("unsafe id accepted: %q", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type fakeGrowthService struct {
|
||||
get func(int64, string) (growthreport.Record, error)
|
||||
create func() (growthreport.Record, bool, error)
|
||||
}
|
||||
|
||||
func (f fakeGrowthService) Create(_ context.Context, _, _ int64, _ growthreport.CreateRequest, _ string) (growthreport.Record, bool, error) {
|
||||
return f.create()
|
||||
}
|
||||
func (f fakeGrowthService) Get(_ context.Context, merchantID int64, id string) (growthreport.Record, error) {
|
||||
return f.get(merchantID, id)
|
||||
}
|
||||
func (f fakeGrowthService) Latest(_ context.Context, merchantID int64, terminal string) (growthreport.Record, error) {
|
||||
return f.get(merchantID, terminal)
|
||||
}
|
||||
|
||||
func TestGrowthEndpointsRequireAuthentication(t *testing.T) {
|
||||
t.Parallel()
|
||||
server := &Server{logger: slog.New(slog.NewTextHandler(io.Discard, nil)), growth: fakeGrowthService{}}
|
||||
request := httptest.NewRequest("GET", "/api/v1/analytics/growth-reports/latest", nil)
|
||||
response := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(response, request)
|
||||
if response.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status=%d", response.Code)
|
||||
}
|
||||
}
|
||||
func TestGetGrowthReportHidesOtherMerchantResource(t *testing.T) {
|
||||
t.Parallel()
|
||||
service := fakeGrowthService{get: func(merchantID int64, _ string) (growthreport.Record, error) {
|
||||
if merchantID != 7 {
|
||||
t.Fatalf("merchant=%d", merchantID)
|
||||
}
|
||||
return growthreport.Record{}, growthreport.ErrNotFound
|
||||
}}
|
||||
server := &Server{logger: slog.New(slog.NewTextHandler(io.Discard, nil)), growth: service}
|
||||
request := httptest.NewRequest("GET", "/", nil)
|
||||
request.SetPathValue("reportId", "growth_0123456789abcdef01234567")
|
||||
request = request.WithContext(context.WithValue(request.Context(), sessionKey{}, identity.Session{MerchantID: 7}))
|
||||
response := httptest.NewRecorder()
|
||||
server.getGrowthReport(response, request)
|
||||
if response.Code != 404 {
|
||||
t.Fatalf("status=%d", response.Code)
|
||||
}
|
||||
}
|
||||
func TestRefreshLimitProblemIncludesNextRefreshAt(t *testing.T) {
|
||||
t.Parallel()
|
||||
next := time.Now().UTC().Add(time.Hour)
|
||||
service := fakeGrowthService{create: func() (growthreport.Record, bool, error) {
|
||||
return growthreport.Record{}, false, &growthreport.RefreshLimitError{NextRefreshAt: next}
|
||||
}}
|
||||
server := &Server{logger: slog.New(slog.NewTextHandler(io.Discard, nil)), growth: service}
|
||||
request := httptest.NewRequest("POST", "/api/v1/analytics/growth-reports", strings.NewReader(`{"refresh":true}`))
|
||||
request.Header.Set("Idempotency-Key", "growth-refresh-1")
|
||||
request = request.WithContext(context.WithValue(request.Context(), sessionKey{}, identity.Session{MerchantID: 7, UserID: 3}))
|
||||
response := httptest.NewRecorder()
|
||||
server.createGrowthReport(response, request)
|
||||
if response.Code != 429 || !strings.Contains(response.Body.String(), "nextRefreshAt") {
|
||||
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
func TestGrowthSSEAuthorizesBeforeSettingStreamHeaders(t *testing.T) {
|
||||
t.Parallel()
|
||||
service := fakeGrowthService{get: func(int64, string) (growthreport.Record, error) {
|
||||
return growthreport.Record{}, growthreport.ErrNotFound
|
||||
}}
|
||||
server := &Server{logger: slog.New(slog.NewTextHandler(io.Discard, nil)), growth: service}
|
||||
request := httptest.NewRequest("GET", "/", nil)
|
||||
request.SetPathValue("reportId", "growth_0123456789abcdef01234567")
|
||||
request = request.WithContext(context.WithValue(request.Context(), sessionKey{}, identity.Session{MerchantID: 7}))
|
||||
response := httptest.NewRecorder()
|
||||
server.growthReportEvents(response, request)
|
||||
if response.Code != 404 || !strings.HasPrefix(response.Header().Get("Content-Type"), "application/problem+json") {
|
||||
t.Fatalf("status=%d content-type=%q", response.Code, response.Header().Get("Content-Type"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user