fix: harden terminal-scoped analytics contracts

Keep JSON and bounded CSV exports on one canonical predicate, replace replayable offsets with encrypted tenant/filter-bound keyset cursors, and suppress merchant-grain peers under terminal scope. Validate period filters and align OpenAPI and capability specs with runtime behavior.
This commit is contained in:
2026-08-20 21:29:18 +03:30
parent 857f29635e
commit d6de48fd15
14 changed files with 577 additions and 103 deletions

View File

@@ -1,6 +1,14 @@
package api
import "testing"
import (
"errors"
"net/http/httptest"
"strings"
"testing"
"time"
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/analytics"
)
func TestServerCookieName(t *testing.T) {
t.Parallel()
@@ -22,6 +30,25 @@ func TestServerCookieName(t *testing.T) {
}
}
func TestRenderExportCSVIsBoundedAndFormulaSafe(t *testing.T) {
t.Parallel()
item := analytics.ExportDetail{Detail: analytics.Detail{
SessionID: "=session", TerminalID: "+terminal", SessionStatus: "Verified",
AmountRial: 1000, FeeAmountRial: 10, CreatedAt: time.Date(2026, time.June, 20, 8, 0, 0, 0, time.UTC),
}}
encoded, err := renderExportCSV([]analytics.ExportDetail{item}, 4096)
if err != nil {
t.Fatal(err)
}
text := string(encoded)
if !strings.Contains(text, "'=session") || !strings.Contains(text, "'+terminal") {
t.Fatalf("renderExportCSV() did not neutralize formulas: %q", text)
}
if _, err := renderExportCSV([]analytics.ExportDetail{item}, 10); !errors.Is(err, errExportBytesExceeded) {
t.Fatalf("renderExportCSV() error = %v, want errExportBytesExceeded", err)
}
}
func TestPersianStatus(t *testing.T) {
t.Parallel()
if got := persianStatus("Verified"); got != "تأیید شده" {
@@ -60,3 +87,39 @@ func equalInt64Pointers(left, right *int64) bool {
}
return *left == *right
}
func TestDetailFilterFromRequestParsesCanonicalFilters(t *testing.T) {
t.Parallel()
request := httptest.NewRequest("GET", "/?view=ALL&failureStage=&terminalId=T318&status=Verified&minAmountRial=1000&maxAmountRial=9000&cursor=opaque", nil)
filter, err := detailFilterFromRequest(request, 25)
if err != nil {
t.Fatal(err)
}
if filter.View != "ALL" || filter.TerminalID != "T318" || filter.Status != "Verified" || filter.Cursor != "opaque" || filter.Limit != 25 {
t.Fatalf("detailFilterFromRequest() = %+v", filter)
}
if filter.MinAmountRial == nil || *filter.MinAmountRial != 1000 || filter.MaxAmountRial == nil || *filter.MaxAmountRial != 9000 {
t.Fatalf("detailFilterFromRequest() amount bounds = %v/%v", filter.MinAmountRial, filter.MaxAmountRial)
}
}
func TestDetailFilterFromRequestRejectsDescendingAmountRange(t *testing.T) {
t.Parallel()
request := httptest.NewRequest("GET", "/?view=ALL&minAmountRial=9000&maxAmountRial=1000", nil)
if _, err := detailFilterFromRequest(request, 25); err == nil {
t.Fatal("detailFilterFromRequest() error = nil")
}
}
func TestFilterPeriodsByType(t *testing.T) {
t.Parallel()
periods := []analytics.Period{{PeriodType: "DAY"}, {PeriodType: "WEEK"}}
filtered, err := filterPeriodsByType(periods, "DAY")
if err != nil || len(filtered) != 1 || filtered[0].PeriodType != "DAY" || !filtered[0].IsDefault {
t.Fatalf("filterPeriodsByType() = %+v, %v", filtered, err)
}
if _, err := filterPeriodsByType(periods, "MONTH"); err == nil {
t.Fatal("filterPeriodsByType() accepted invalid type")
}
}