195 lines
8.3 KiB
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)
|
|
}
|
|
}
|