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.
117 lines
4.1 KiB
Go
117 lines
4.1 KiB
Go
package analytics
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestDetailFilterValidate(t *testing.T) {
|
|
t.Parallel()
|
|
zero := int64(0)
|
|
hundred := int64(100)
|
|
twoHundred := int64(200)
|
|
tests := []struct {
|
|
name string
|
|
filter DetailFilter
|
|
wantErr bool
|
|
}{
|
|
{name: "all transactions", filter: DetailFilter{View: "ALL", Limit: 25}},
|
|
{name: "allowlisted status", filter: DetailFilter{View: "ALL", Status: "Verified", Limit: 25}},
|
|
{name: "amount range", filter: DetailFilter{View: "ALL", MinAmountRial: &zero, MaxAmountRial: &hundred, Limit: 25}},
|
|
{name: "failed observed stage", filter: DetailFilter{View: "FAILED", FailureStage: "REACHED_BANK", Limit: 25}},
|
|
{name: "unknown view", filter: DetailFilter{View: "EVERYTHING", Limit: 25}, wantErr: true},
|
|
{name: "unknown status", filter: DetailFilter{View: "ALL", Status: "Successful", Limit: 25}, wantErr: true},
|
|
{name: "stage outside failed view", filter: DetailFilter{View: "ALL", FailureStage: "NO_ATTEMPT", Limit: 25}, wantErr: true},
|
|
{name: "descending amount range", filter: DetailFilter{View: "ALL", MinAmountRial: &twoHundred, MaxAmountRial: &hundred, Limit: 25}, wantErr: true},
|
|
{name: "page too large", filter: DetailFilter{View: "ALL", Limit: 101}, wantErr: true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
err := tt.filter.Validate()
|
|
if (err != nil) != tt.wantErr {
|
|
t.Fatalf("Validate() error = %v, wantErr %v", err, tt.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSessionSourceKey(t *testing.T) {
|
|
t.Parallel()
|
|
tests := []struct {
|
|
name, input, want string
|
|
valid bool
|
|
}{
|
|
{name: "valid opaque id", input: "ses_abc-123", want: "abc-123", valid: true},
|
|
{name: "missing prefix", input: "abc-123"},
|
|
{name: "empty source", input: "ses_"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got, valid := sessionSourceKey(tt.input)
|
|
if got != tt.want || valid != tt.valid {
|
|
t.Fatalf("sessionSourceKey(%q) = %q,%v, want %q,%v", tt.input, got, valid, tt.want, tt.valid)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestTerminalDisplayName(t *testing.T) {
|
|
t.Parallel()
|
|
if got := terminalDisplayName("terminal-12345678"); got != "پایانه ۵۶۷۸" {
|
|
t.Fatalf("terminalDisplayName() = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestDetailCursorRejectsMerchantAndFilterReplay(t *testing.T) {
|
|
t.Parallel()
|
|
service := &Service{cursorSecret: []byte("test-cursor-secret-that-is-at-least-32-bytes")}
|
|
period := Period{PeriodID: "jm-1405-03"}
|
|
minimum := int64(1000)
|
|
filter := DetailFilter{View: "ALL", TerminalID: "T318", Status: "Verified", MinAmountRial: &minimum, Limit: 25}
|
|
createdAt := time.Date(2026, time.June, 20, 8, 30, 0, 0, time.UTC)
|
|
|
|
cursor, err := service.encodeDetailCursor(42, period, filter, createdAt, 99)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
position, err := service.decodeDetailCursor(42, period, filter, cursor)
|
|
if err != nil {
|
|
t.Fatalf("decodeDetailCursor() error = %v", err)
|
|
}
|
|
if !position.CreatedAt.Equal(createdAt) || position.SessionID != 99 {
|
|
t.Fatalf("decodeDetailCursor() = %+v", position)
|
|
}
|
|
|
|
if _, err := service.decodeDetailCursor(43, period, filter, cursor); !errors.Is(err, ErrInvalidCursor) {
|
|
t.Fatalf("merchant replay error = %v, want ErrInvalidCursor", err)
|
|
}
|
|
changed := filter
|
|
changed.Status = "Paid"
|
|
if _, err := service.decodeDetailCursor(42, period, changed, cursor); !errors.Is(err, ErrInvalidCursor) {
|
|
t.Fatalf("filter replay error = %v, want ErrInvalidCursor", err)
|
|
}
|
|
}
|
|
|
|
func TestDetailCursorRejectsTampering(t *testing.T) {
|
|
t.Parallel()
|
|
service := &Service{cursorSecret: []byte("test-cursor-secret-that-is-at-least-32-bytes")}
|
|
period := Period{PeriodID: "jm-1405-03"}
|
|
filter := DetailFilter{View: "ALL", Limit: 25}
|
|
cursor, err := service.encodeDetailCursor(42, period, filter, time.Now().UTC(), 99)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sealed, err := base64.RawURLEncoding.DecodeString(cursor)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sealed[len(sealed)-1] ^= 1
|
|
tampered := base64.RawURLEncoding.EncodeToString(sealed)
|
|
if _, err := service.decodeDetailCursor(42, period, filter, tampered); !errors.Is(err, ErrInvalidCursor) {
|
|
t.Fatalf("tampered cursor error = %v, want ErrInvalidCursor", err)
|
|
}
|
|
}
|