package growthreport import ( "fmt" "strings" "testing" "time" "git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/growth" ) func TestUpcomingEventsKeepsFiveChronologicalEligibleCandidates(t *testing.T) { t.Parallel() start := time.Date(2026, time.August, 22, 0, 0, 0, 0, time.UTC) catalog := []growth.Event{ {ID: "past", TitleFA: "رویداد گذشته", StartDate: start.AddDate(0, 0, -1)}, {ID: "one", TitleFA: "رویداد اول", StartDate: start}, {ID: "two", TitleFA: "رویداد دوم", StartDate: start.AddDate(0, 0, 40)}, {ID: "three", TitleFA: "رویداد سوم", StartDate: start.AddDate(0, 0, 100)}, {ID: "four", TitleFA: "رویداد چهارم", StartDate: start.AddDate(0, 0, 190)}, {ID: "five", TitleFA: "رویداد پنجم", StartDate: start.AddDate(0, 0, 280), OfficialHoliday: true}, {ID: "six", TitleFA: "رویداد ششم", StartDate: start.AddDate(0, 0, 320)}, } got := upcomingEvents(catalog, start, 5) if len(got) != 5 { t.Fatalf("upcomingEvents() returned %d events, want 5", len(got)) } for index, want := range []string{"event:one", "event:two", "event:three", "event:four", "event:five"} { if got[index].EvidenceID != want { t.Fatalf("upcomingEvents()[%d] = %q, want %q", index, got[index].EvidenceID, want) } } if !got[4].OfficialHoliday { t.Fatal("eligible official holiday was removed from the candidate set") } } func TestFallbackEventRecommendationsUseTheFirstThreeCandidates(t *testing.T) { t.Parallel() events := []UpcomingEvent{ {EvidenceID: "event:first", Date: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), InsightBasis: InsightBasisHistorical, HistoricalEvidence: &HistoricalEvidence{MaterialDecline: true}, RankingScore: 90}, {EvidenceID: "event:second", Date: time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC), InsightBasis: InsightBasisBusinessProfile, ProfileEvidence: &ProfileEvidence{MatchedSignals: []string{"فروش آنلاین"}}, RankingScore: 60}, {EvidenceID: "event:third", Date: time.Date(2026, 1, 3, 0, 0, 0, 0, time.UTC), InsightBasis: InsightBasisBusinessProfile, ProfileEvidence: &ProfileEvidence{}, RankingScore: 30}, {EvidenceID: "event:fourth", Date: time.Date(2026, 1, 4, 0, 0, 0, 0, time.UTC), RankingScore: 1}, } got := fallbackEventRecommendations(events) if len(got) != 3 || got[0].EvidenceID != "event:first" || got[2].EvidenceID != "event:third" { t.Fatalf("fallbackEventRecommendations() = %+v", got) } if got[0].Stance != StanceCaution || got[0].InsightBasis != InsightBasisHistorical { t.Fatalf("historical decline fallback = %+v, want historical caution", got[0]) } total := 0 for index, item := range got { total += item.PriorityPercent if item.PriorityPercent <= 0 || index > 0 && got[index-1].PriorityPercent < item.PriorityPercent { t.Fatalf("fallback percentages are not positive descending: %+v", got) } } if total != 100 { t.Fatalf("fallback percentage sum = %d, want 100", total) } for _, item := range got { if item.RelevanceReason == "" { t.Fatal("fallback recommendation lacks a relevance reason") } } } func TestFallbackEventRecommendationsUsesStableEqualShares(t *testing.T) { t.Parallel() events := []UpcomingEvent{ {EvidenceID: "event:a", Date: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), InsightBasis: InsightBasisBusinessProfile, RankingScore: 10}, {EvidenceID: "event:b", Date: time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC), InsightBasis: InsightBasisBusinessProfile, RankingScore: 10}, {EvidenceID: "event:c", Date: time.Date(2026, 1, 3, 0, 0, 0, 0, time.UTC), InsightBasis: InsightBasisBusinessProfile, RankingScore: 10}, } got := fallbackEventRecommendations(events) if got[0].PriorityPercent != 34 || got[1].PriorityPercent != 33 || got[2].PriorityPercent != 33 { t.Fatalf("equal fallback shares = %+v, want 34/33/33", got) } } func TestFallbackEventRecommendationsNeverPromotesNonPromotableTone(t *testing.T) { t.Parallel() events := []UpcomingEvent{ {EvidenceID: "event:a", InsightBasis: InsightBasisBusinessProfile, CommercialPosture: "PROMOTE", Tone: "MOURNING", RankingScore: 30}, {EvidenceID: "event:b", InsightBasis: InsightBasisBusinessProfile, CommercialPosture: "PROMOTE", RankingScore: 20}, {EvidenceID: "event:c", InsightBasis: InsightBasisBusinessProfile, CommercialPosture: "PROMOTE", RankingScore: 10}, } got := fallbackEventRecommendations(events) if got[0].Stance == StancePromote { t.Fatalf("non-promotable tone received PROMOTE fallback: %+v", got[0]) } } func TestApplyEventEvidencePrefersEligibleExactHistory(t *testing.T) { t.Parallel() prior := growth.Event{ID: "prior", StartDate: time.Date(2025, 11, 28, 0, 0, 0, 0, time.UTC), EndDate: time.Date(2025, 11, 29, 0, 0, 0, 0, time.UTC)} effect := growth.EventEffect{Eligible: true, ObservationCount: 6, PaidSessionUpliftBPS: -2300, AmountUpliftBPS: -1200, MaterialDecline: true} event := UpcomingEvent{EvidenceID: "event:black-friday", InsightBasis: InsightBasisBusinessProfile, ProfileEvidence: &ProfileEvidence{MatchedSignals: []string{"وب‌سایت"}}, RankingScore: 100} got := applyEventEvidence(event, &prior, effect, growth.SuppressionInsufficientHistory) if got.InsightBasis != InsightBasisHistorical || got.HistoricalEvidence == nil || got.ProfileEvidence != nil { t.Fatalf("applyEventEvidence() = %+v, want historical-only evidence", got) } if got.HistoricalEvidence.PriorStartDate != prior.StartDate || got.HistoricalEvidence.PaidSessionUpliftBPS != -2300 || !got.HistoricalEvidence.MaterialDecline { t.Fatalf("historical evidence = %+v", got.HistoricalEvidence) } if got.RankingScore <= event.RankingScore { t.Fatalf("historical evidence did not increase ranking score: got %d, base %d", got.RankingScore, event.RankingScore) } } func TestApplyEventEvidenceFallsBackWithSuppressionReason(t *testing.T) { t.Parallel() event := UpcomingEvent{EvidenceID: "event:yalda", InsightBasis: InsightBasisBusinessProfile, ProfileEvidence: &ProfileEvidence{MatchedSignals: []string{"فروش آنلاین"}}} got := applyEventEvidence(event, nil, growth.EventEffect{}, growth.SuppressionInsufficientHistory) if got.InsightBasis != InsightBasisBusinessProfile || got.HistoricalEvidence != nil || got.ProfileEvidence == nil { t.Fatalf("applyEventEvidence() = %+v, want profile-only evidence", got) } if got.ProfileEvidence.HistoryUnavailableReason != growth.SuppressionInsufficientHistory { t.Fatalf("history unavailable reason = %q", got.ProfileEvidence.HistoryUnavailableReason) } } func TestMatchedProfileSignalsDoesNotFabricateCategoryMatch(t *testing.T) { t.Parallel() event := growth.Event{Categories: []string{"رستوران"}, Tags: []string{"حضوری"}} context := MerchantContext{Category: "فروشگاه پوشاک", Niches: []string{"پوشاک کودک"}, SalesChannels: []string{"وب‌سایت"}} got := matchedProfileSignals(event, context) if len(got) != 0 { t.Fatalf("matchedProfileSignals() = %+v, want no fabricated match", got) } } func TestRecommendationFallbackRequiresAtLeastThreeCandidates(t *testing.T) { t.Parallel() for _, count := range []int{0, 1, 2} { events := make([]UpcomingEvent, count) fallback, shouldCallAI := recommendationFallback(events) if fallback != nil || shouldCallAI { t.Fatalf("recommendationFallback(%d events) = (%+v, %v), want nil,false", count, fallback, shouldCallAI) } } for _, count := range []int{3, 4, 5} { events := make([]UpcomingEvent, count) for index := range events { events[index] = UpcomingEvent{EvidenceID: fmt.Sprintf("event:%d", index), InsightBasis: InsightBasisBusinessProfile, RankingScore: int64(count - index)} } fallback, shouldCallAI := recommendationFallback(events) if fallback == nil || len(fallback.EventRecommendations) != 3 || !shouldCallAI { t.Fatalf("recommendationFallback(%d events) = (%+v, %v), want three recommendations,true", count, fallback, shouldCallAI) } } } func TestMergeMerchantContextsDeduplicatesCurrentProfilesDeterministically(t *testing.T) { t.Parallel() got := mergeMerchantContexts("فروشگاه", []MerchantContext{ {BusinessDescriptions: []string{"فروش پوشاک"}, Niches: []string{"پوشاک", "هدیه"}, SalesChannels: []string{"وب‌سایت"}}, {BusinessDescriptions: []string{"فروش هدیه", "فروش پوشاک"}, Niches: []string{"هدیه"}, SalesChannels: []string{"اینستاگرام", "وب‌سایت"}}, }) if strings.Join(got.BusinessDescriptions, ",") != "فروش پوشاک,فروش هدیه" || strings.Join(got.Niches, ",") != "پوشاک,هدیه" || strings.Join(got.SalesChannels, ",") != "وب‌سایت,اینستاگرام" { t.Fatalf("mergeMerchantContexts() = %+v", got) } } func TestMergeMerchantContextsBoundsModelProfilePayload(t *testing.T) { t.Parallel() profiles := make([]MerchantContext, 0, 7) for index := range 7 { profiles = append(profiles, MerchantContext{BusinessDescriptions: []string{strings.Repeat(string(rune('آ'+index)), 400)}, Niches: []string{fmt.Sprintf("حوزه-%d", index)}, SalesChannels: []string{fmt.Sprintf("کانال-%d", index)}}) } got := mergeMerchantContexts("فروشگاه", profiles) if len(got.BusinessDescriptions) != 5 { t.Fatalf("business descriptions = %d, want bounded to 5", len(got.BusinessDescriptions)) } for _, description := range got.BusinessDescriptions { if len([]rune(description)) > 320 { t.Fatalf("description contains %d runes, want at most 320", len([]rune(description))) } } }