package hook import ( "context" "errors" "strings" "sync" "testing" "time" "github.com/DATA-DOG/go-sqlmock" "github.com/perfect-panel/server/internal/model/lottery" "gorm.io/driver/mysql" "gorm.io/gorm" ) func newHookTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) { t.Helper() sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(func(expected, actual string) error { if strings.Contains(actual, expected) { return nil } return errors.New("actual sql does not contain expected: " + expected) }))) if err != nil { t.Fatalf("create sqlmock: %v", err) } db, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{SkipDefaultTransaction: true}) if err != nil { _ = sqlDB.Close() t.Fatalf("open gorm db: %v", err) } return db, mock, func() { _ = sqlDB.Close() } } type chanceCall struct { userId, activityId int64 source, sourceRef string amount int } type fakeChanceService struct { mu sync.Mutex calls []chanceCall err error } func (f *fakeChanceService) Grant(_ context.Context, userId, activityId int64, source, sourceRef string, amount int) error { f.mu.Lock() defer f.mu.Unlock() f.calls = append(f.calls, chanceCall{userId, activityId, source, sourceRef, amount}) return f.err } func (*fakeChanceService) Consume(context.Context, *gorm.DB, int64, int64) (int64, error) { return 0, nil } func (*fakeChanceService) Query(context.Context, int64, int64) (int64, error) { return 0, nil } func (f *fakeChanceService) recorded() []chanceCall { f.mu.Lock() defer f.mu.Unlock() out := make([]chanceCall, len(f.calls)) copy(out, f.calls) return out } func TestNoopInviteHook_IsInert(t *testing.T) { NoopInviteHook().OnConversion(context.Background(), 1, "ord") } func TestInviteHook_SkipsWhenRefererMissing(t *testing.T) { chance := &fakeChanceService{} h := NewInviteHook(&gorm.DB{}, chance) // won't touch DB because refererUserID=0 h.OnConversion(context.Background(), 0, "ord") // No goroutine means no calls; give scheduler a beat and confirm empty. time.Sleep(20 * time.Millisecond) if len(chance.recorded()) != 0 { t.Fatalf("expected no Grant when refererUserID=0, got %+v", chance.recorded()) } } func TestInviteHook_GrantsForEachRunningActivity(t *testing.T) { db, mock, cleanup := newHookTestDB(t) defer cleanup() // Activity 100: single invite_success source, amount=1 // Activity 200: two sources, only invite_success (amount=2) counts // Activity 300: has invite_success amount=0 → skipped mock.ExpectQuery("FROM `lottery_activity`"). WillReturnRows(sqlmock.NewRows([]string{"id", "chance_sources", "status", "start_at", "end_at", "eligibility", "grid_size", "unmet_action"}). AddRow(int64(100), `[{"source":"invite_success","amount":1}]`, "running", time.Now(), time.Now().Add(24*time.Hour), "{}", 9, "block"). AddRow(int64(200), `[{"source":"daily_signin","amount":1},{"source":"invite_success","amount":2}]`, "running", time.Now(), time.Now().Add(24*time.Hour), "{}", 9, "block"). AddRow(int64(300), `[{"source":"invite_success","amount":0}]`, "running", time.Now(), time.Now().Add(24*time.Hour), "{}", 9, "block")) chance := &fakeChanceService{} h := NewInviteHook(db, chance) h.OnConversion(context.Background(), 42, "order-xyz") // give the goroutine time to complete deadline := time.After(2 * time.Second) for len(chance.recorded()) < 2 { select { case <-deadline: t.Fatalf("timed out waiting for grants; got %+v", chance.recorded()) case <-time.After(20 * time.Millisecond): } } calls := chance.recorded() if len(calls) != 2 { t.Fatalf("expected 2 grants (100 amount=1, 200 amount=2), got %+v", calls) } byActivity := map[int64]int{} for _, c := range calls { if c.source != lottery.ChanceSourceInviteSuccess { t.Fatalf("unexpected source: %+v", c) } if c.sourceRef != "order:order-xyz" { t.Fatalf("expected orderNo prefixed as source_ref, got %q", c.sourceRef) } if c.userId != 42 { t.Fatalf("expected referer=42, got %d", c.userId) } byActivity[c.activityId] = c.amount } if byActivity[100] != 1 || byActivity[200] != 2 { t.Fatalf("wrong amounts: %+v", byActivity) } if _, exists := byActivity[300]; exists { t.Fatalf("activity 300 has invite_success amount=0 and must be skipped") } } func TestInviteHook_MalformedChanceSourcesSkipsOnly(t *testing.T) { db, mock, cleanup := newHookTestDB(t) defer cleanup() mock.ExpectQuery("FROM `lottery_activity`"). WillReturnRows(sqlmock.NewRows([]string{"id", "chance_sources", "status", "start_at", "end_at", "eligibility", "grid_size", "unmet_action"}). AddRow(int64(100), `[{"source":"invite_success","amount":3}]`, "running", time.Now(), time.Now().Add(24*time.Hour), "{}", 9, "block"). AddRow(int64(200), `{bad-json`, "running", time.Now(), time.Now().Add(24*time.Hour), "{}", 9, "block")) chance := &fakeChanceService{} h := NewInviteHook(db, chance) h.OnConversion(context.Background(), 42, "order-1") deadline := time.After(2 * time.Second) for len(chance.recorded()) < 1 { select { case <-deadline: t.Fatalf("timed out; got %+v", chance.recorded()) case <-time.After(20 * time.Millisecond): } } // Only the well-formed activity should have been granted; malformed skipped silently. calls := chance.recorded() if len(calls) != 1 || calls[0].activityId != 100 { t.Fatalf("expected exactly 1 grant for activity 100, got %+v", calls) } } func TestInviteHook_QueryFailureLogsAndReturns(t *testing.T) { db, mock, cleanup := newHookTestDB(t) defer cleanup() mock.ExpectQuery("FROM `lottery_activity`"). WillReturnError(errors.New("db down")) chance := &fakeChanceService{} h := NewInviteHook(db, chance) h.OnConversion(context.Background(), 42, "ord") time.Sleep(200 * time.Millisecond) if len(chance.recorded()) != 0 { t.Fatalf("expected no grants when query fails, got %+v", chance.recorded()) } } func TestInviteHook_ParseHelperExposesInviteAmountsOnly(t *testing.T) { got := parseInviteGrantsFromSources(`[{"source":"invite_success","amount":5},{"source":"daily_signin","amount":9},{"source":"invite_success","amount":0}]`) if len(got) != 1 || got[0] != 5 { t.Fatalf("expected [5], got %v", got) } if got := parseInviteGrantsFromSources(""); got != nil { t.Fatalf("empty string should return nil, got %v", got) } if got := parseInviteGrantsFromSources(`{bad`); got != nil { t.Fatalf("bad json should return nil, got %v", got) } }