170 lines
5.2 KiB
Go
170 lines
5.2 KiB
Go
|
|
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
|
||
|
|
}
|