Files
zarinpal-analytics-backend/internal/api/growth_test.go

195 lines
8.3 KiB
Go

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)
getContext func(context.Context, 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(ctx context.Context, merchantID int64, id string) (growthreport.Record, error) {
if f.getContext != nil {
return f.getContext(ctx, merchantID, id)
}
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"))
}
}
func TestGrowthSSERejectsWhenGlobalCapacityIsFull(t *testing.T) {
t.Parallel()
slots := make(chan struct{}, 1)
slots <- struct{}{}
reads := 0
service := fakeGrowthService{get: func(int64, string) (growthreport.Record, error) {
reads++
return growthreport.Record{Report: growthreport.Report{ReportID: "growth_0123456789abcdef01234567", Status: growthreport.StatusQueued}}, nil
}}
server := &Server{logger: slog.New(slog.NewTextHandler(io.Discard, nil)), growth: service, growthSSESlots: slots}
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 != http.StatusTooManyRequests || reads != 0 {
t.Fatalf("status=%d database reads=%d", response.Code, reads)
}
}
func TestGrowthSSEEndsAtConfiguredDeadline(t *testing.T) {
t.Parallel()
reads := 0
service := fakeGrowthService{get: func(int64, string) (growthreport.Record, error) {
reads++
return growthreport.Record{Report: growthreport.Report{ReportID: "growth_0123456789abcdef01234567", Status: growthreport.StatusQueued, UpdatedAt: time.Unix(int64(reads), 0)}}, nil
}}
server := &Server{logger: slog.New(slog.NewTextHandler(io.Discard, nil)), growth: service, growthSSESlots: make(chan struct{}, 1), growthSSEMaxDuration: 5 * time.Millisecond, growthSSEPollInterval: time.Millisecond}
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()
started := time.Now()
server.growthReportEvents(response, request)
if elapsed := time.Since(started); elapsed > 250*time.Millisecond {
t.Fatalf("stream remained open for %s", elapsed)
}
if reads < 1 || reads > 10 {
t.Fatalf("database reads=%d", reads)
}
}
func TestGrowthSSEBoundsInitialDatabaseRead(t *testing.T) {
t.Parallel()
service := fakeGrowthService{getContext: func(ctx context.Context, _ int64, _ string) (growthreport.Record, error) {
<-ctx.Done()
return growthreport.Record{}, ctx.Err()
}}
server := &Server{logger: slog.New(slog.NewTextHandler(io.Discard, nil)), growth: service, growthSSESlots: make(chan struct{}, 1), growthSSEMaxDuration: 5 * time.Millisecond}
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()
started := time.Now()
server.growthReportEvents(response, request)
if elapsed := time.Since(started); elapsed > 250*time.Millisecond || response.Code != http.StatusServiceUnavailable {
t.Fatalf("elapsed=%s status=%d", elapsed, response.Code)
}
}