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) } }