package usecase import ( "context" "testing" "time" "apps/backend/internal/module/crm/domain" "apps/backend/internal/module/crm/repository" ) const day = int64(24 * time.Hour) func seedFollowUp(t *testing.T, repo domain.Repository, days int) (*domain.Contact, *domain.FollowUp) { t.Helper() ctx := context.Background() c, err := repo.UpsertContactByIdentity(ctx, &domain.Contact{ OwnerUID: 7, AuthorHandle: "buyer", FollowUpDays: days, }) if err != nil { t.Fatal(err) } f := &domain.FollowUp{ ID: domain.NewID(), OwnerUID: 7, ContactID: c.ID, DueAt: 1, Status: domain.FollowUpScheduled, CreatedAt: 1, UpdatedAt: 1, } if err := repo.SaveFollowUp(ctx, f); err != nil { t.Fatal(err) } return c, f } // FU-01/FU-03:第一次通知後要再等一個間隔才第二次通知,第二次之後才 escalated。 func TestScanFollowUpsNotifiesTwiceThenEscalates(t *testing.T) { ctx := context.Background() repo := repository.NewMemory() svc := New(repo) _, f := seedFollowUp(t, repo, 3) now := int64(10 * day) if n, err := svc.ScanFollowUps(ctx, now); err != nil || n != 1 { t.Fatalf("first scan n=%d err=%v", n, err) } first, err := repo.GetFollowUp(ctx, f.ID) if err != nil || first.Status != domain.FollowUpNotified || first.NotifiedCount != 1 { t.Fatalf("after first scan=%+v err=%v", first, err) } if first.DueAt != now+3*day { t.Fatalf("first notify must push due_at by the contact interval, got %d", first.DueAt) } // 還沒到下一次到期:不可重複通知 if n, err := svc.ScanFollowUps(ctx, now+day); err != nil || n != 0 { t.Fatalf("premature rescan n=%d err=%v", n, err) } if n, err := svc.ScanFollowUps(ctx, now+3*day); err != nil || n != 1 { t.Fatalf("second scan n=%d err=%v", n, err) } second, err := repo.GetFollowUp(ctx, f.ID) if err != nil || second.Status != domain.FollowUpEscalated || second.NotifiedCount != 2 { t.Fatalf("after second scan=%+v err=%v", second, err) } // escalated 是終點:不再被掃到 if n, err := svc.ScanFollowUps(ctx, now+30*day); err != nil || n != 0 { t.Fatalf("escalated must stop scanning n=%d err=%v", n, err) } } // FU-04:延後把到期日後移並回到 scheduled。 func TestSnoozeReturnsToScheduled(t *testing.T) { ctx := context.Background() repo := repository.NewMemory() svc := New(repo) _, f := seedFollowUp(t, repo, 3) if _, err := svc.ScanFollowUps(ctx, 10*day); err != nil { t.Fatal(err) } before := domain.NowNano() got, err := svc.SnoozeFollowUp(ctx, 7, f.ID, 5) if err != nil { t.Fatal(err) } if got.Status != domain.FollowUpScheduled { t.Fatalf("snooze status = %s, want scheduled", got.Status) } if got.DueAt < before+5*day { t.Fatalf("snooze must push due_at at least 5 days out, got %d", got.DueAt) } } func TestScanFollowUpsFallsBackToDefaultInterval(t *testing.T) { ctx := context.Background() repo := repository.NewMemory() svc := New(repo) f := &domain.FollowUp{ ID: domain.NewID(), OwnerUID: 7, ContactID: "missing-contact", DueAt: 1, Status: domain.FollowUpScheduled, CreatedAt: 1, UpdatedAt: 1, } if err := repo.SaveFollowUp(ctx, f); err != nil { t.Fatal(err) } now := int64(10 * day) if _, err := svc.ScanFollowUps(ctx, now); err != nil { t.Fatal(err) } got, err := repo.GetFollowUp(ctx, f.ID) if err != nil || got.DueAt != now+int64(domain.DefaultFollowUpDays)*day { t.Fatalf("missing contact should use default interval, got %+v err=%v", got, err) } }