thread-master/apps/backend/internal/module/crm/usecase/stats_test.go

170 lines
5.2 KiB
Go
Raw Permalink Normal View History

2026-08-19 02:01:15 +00:00
package usecase
import (
"context"
"testing"
"apps/backend/internal/module/crm/domain"
"apps/backend/internal/module/crm/repository"
radarDomain "apps/backend/internal/module/radar/domain"
)
type stubRadarOpps struct{ opps []*radarDomain.Opportunity }
func (s stubRadarOpps) GetOpportunity(_ context.Context, id string) (*radarDomain.Opportunity, error) {
for _, o := range s.opps {
if o.ID == id {
return o, nil
}
}
return nil, radarDomain.ErrNotFound
}
func (s stubRadarOpps) ListOpportunities(
_ context.Context, ownerUID int64, f radarDomain.OpportunityListFilter,
) ([]*radarDomain.Opportunity, int64, error) {
matched := make([]*radarDomain.Opportunity, 0, len(s.opps))
for _, o := range s.opps {
if o.OwnerUID != ownerUID || o.Status != radarDomain.OppAccepted {
continue
}
if f.CreatedFrom > 0 && o.CreatedAt < f.CreatedFrom {
continue
}
if f.CreatedTo > 0 && o.CreatedAt > f.CreatedTo {
continue
}
matched = append(matched, o)
}
return matched, int64(len(matched)), nil
}
func seedStatsContact(t *testing.T, repo domain.Repository, handle, stage string, at int64) *domain.Contact {
t.Helper()
c, err := repo.UpsertContactByIdentity(context.Background(), &domain.Contact{
OwnerUID: 7, AuthorHandle: handle, Stage: stage, LastTouchAt: at,
})
if err != nil {
t.Fatal(err)
}
c.Stage = stage
c.LastTouchAt = at
if err := repo.SaveContact(context.Background(), c); err != nil {
t.Fatal(err)
}
return c
}
func acceptedOpp(id, contactID, source string, createdAt int64, terms ...string) *radarDomain.Opportunity {
return &radarDomain.Opportunity{
ID: id, OwnerUID: 7, Status: radarDomain.OppAccepted, Source: source,
ContactID: contactID, MatchedTerms: terms, CreatedAt: createdAt,
}
}
func TestStatsAttributesTermsAndSources(t *testing.T) {
ctx := context.Background()
repo := repository.NewMemory()
svc := New(repo)
won := seedStatsContact(t, repo, "won-buyer", domain.StageWon, 100)
replied := seedStatsContact(t, repo, "replied-buyer", domain.StageReplied, 100)
cold := seedStatsContact(t, repo, "cold-buyer", domain.StageNewFound, 100)
svc.RadarOpps = stubRadarOpps{opps: []*radarDomain.Opportunity{
acceptedOpp("o1", won.ID, radarDomain.OppSourceThreads, 100, "婚攝"),
// 同一聯絡人同一關鍵字的第二筆商機不可重複計入 accepted
acceptedOpp("o2", won.ID, radarDomain.OppSourceThreads, 101, "婚攝"),
acceptedOpp("o3", replied.ID, radarDomain.OppSourceThreads, 100, "婚攝"),
acceptedOpp("o4", cold.ID, radarDomain.OppSourceScoutPromote, 100, "外包"),
}}
got, err := svc.Stats(ctx, 7, 0, 0)
if err != nil {
t.Fatal(err)
}
terms := map[string]ConversionStat{}
for _, s := range got.Terms {
terms[s.Key] = s
}
if s := terms["婚攝"]; s.Accepted != 2 || s.Replied != 2 || s.Won != 1 {
t.Fatalf("婚攝 = %+v, want accepted 2 / replied 2 / won 1", s)
}
if s := terms["外包"]; s.Accepted != 1 || s.Replied != 0 || s.Won != 0 {
t.Fatalf("外包 = %+v", s)
}
sources := map[string]ConversionStat{}
for _, s := range got.Sources {
sources[s.Key] = s
}
if s := sources["radar"]; s.Accepted != 2 || s.Won != 1 {
t.Fatalf("radar source = %+v", s)
}
if s := sources["scout"]; s.Accepted != 1 {
t.Fatalf("scout source = %+v", s)
}
}
// 回覆版本維度沒有資料模型支援,必須明說不可用而不是回空清單。
func TestStatsFlagsVariantsUnavailable(t *testing.T) {
svc := New(repository.NewMemory())
svc.RadarOpps = stubRadarOpps{}
got, err := svc.Stats(context.Background(), 7, 0, 0)
if err != nil {
t.Fatal(err)
}
if !hasDimension(got.UnavailableDimensions, DimensionVariants) {
t.Fatalf("variants must be reported unavailable, got %v", got.UnavailableDimensions)
}
if hasDimension(got.UnavailableDimensions, DimensionTerms) {
t.Fatalf("terms is computable when radar is wired, got %v", got.UnavailableDimensions)
}
}
func TestStatsFlagsTermsUnavailableWithoutRadar(t *testing.T) {
svc := New(repository.NewMemory())
got, err := svc.Stats(context.Background(), 7, 0, 0)
if err != nil {
t.Fatal(err)
}
for _, dim := range []string{DimensionVariants, DimensionTerms, DimensionSources} {
if !hasDimension(got.UnavailableDimensions, dim) {
t.Fatalf("%s must be unavailable without radar, got %v", dim, got.UnavailableDimensions)
}
}
}
func TestStatsHonoursDateRange(t *testing.T) {
ctx := context.Background()
repo := repository.NewMemory()
svc := New(repo)
old := seedStatsContact(t, repo, "old-buyer", domain.StageWon, 50)
recent := seedStatsContact(t, repo, "recent-buyer", domain.StageWon, 500)
svc.RadarOpps = stubRadarOpps{opps: []*radarDomain.Opportunity{
acceptedOpp("o1", old.ID, radarDomain.OppSourceThreads, 50, "舊詞"),
acceptedOpp("o2", recent.ID, radarDomain.OppSourceThreads, 500, "新詞"),
}}
got, err := svc.Stats(ctx, 7, 400, 600)
if err != nil {
t.Fatal(err)
}
if got.TotalContacts != 1 || got.ByStage[domain.StageWon] != 1 {
t.Fatalf("range should keep only the recent contact, got %+v", got)
}
if len(got.Terms) != 1 || got.Terms[0].Key != "新詞" {
t.Fatalf("range should keep only the recent term, got %+v", got.Terms)
}
}
func hasDimension(list []string, want string) bool {
for _, v := range list {
if v == want {
return true
}
}
return false
}