- Add internal/crm client with CanonicalJSON and HMAC-SHA256 signing for POST /api/public/leads - Add database migration 0026_crm_deals.sql with crm_deal_id and partial indexes - Add async lead intake dispatch to new CRM in handlers_leads.go - Support dual-mode booking confirmation (CRM_MODE=both/crm_only/bitrix_only) in booking_confirm.go - Add LinkCRM endpoint to bridge_1c.py and onec.Client - Update config and env examples with CRM_API_URL, CRM_LEAD_INTAKE_SECRET, CRM_MODE
183 lines
7.8 KiB
Go
183 lines
7.8 KiB
Go
package api
|
||
|
||
import (
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"dana/internal/bitrix"
|
||
"dana/internal/storage"
|
||
)
|
||
|
||
func TestConfirmDeadline(t *testing.T) {
|
||
cfg := storage.DefaultBookingSettings // 24 ч после записи или 2 ч до приёма
|
||
created := time.Date(2026, 10, 9, 22, 0, 0, 0, moscow)
|
||
cases := []struct {
|
||
name string
|
||
start time.Time
|
||
want time.Time
|
||
}{
|
||
{"приём через три дня — 24 ч после записи", created.Add(72 * time.Hour), created.Add(24 * time.Hour)},
|
||
{"приём завтра утром — за 2 ч до приёма", created.Add(12 * time.Hour), created.Add(10 * time.Hour)},
|
||
{"приём через час — не раньше 30 минут после записи", created.Add(time.Hour), created.Add(30 * time.Minute)},
|
||
}
|
||
for _, c := range cases {
|
||
got, ok := confirmDeadline(created, c.start, cfg)
|
||
if !ok || !got.Equal(c.want) {
|
||
t.Errorf("%s: срок %v, ждали %v", c.name, got, c.want)
|
||
}
|
||
}
|
||
if _, ok := confirmDeadline(created, created.Add(72*time.Hour), storage.BookingSettings{}); ok {
|
||
t.Error("оба правила выключены — автоотмены быть не должно")
|
||
}
|
||
}
|
||
|
||
func TestDecideConfirm(t *testing.T) {
|
||
cfg := storage.DefaultBookingSettings
|
||
created := time.Date(2026, 10, 9, 10, 0, 0, 0, moscow)
|
||
start := created.Add(48 * time.Hour)
|
||
soon := created.Add(time.Hour)
|
||
late := created.Add(25 * time.Hour) // срок (24 ч) прошёл, приём ещё впереди
|
||
cases := []struct {
|
||
name string
|
||
semantic string
|
||
now time.Time
|
||
want confirmAction
|
||
}{
|
||
{"оператор подтвердил", bitrix.SemanticSuccess, soon, confirmOK},
|
||
{"оператор отменил", bitrix.SemanticFailure, soon, confirmCancel},
|
||
{"в работе, срок не вышел", bitrix.SemanticProcess, soon, confirmWait},
|
||
{"лид удалён, срок не вышел", "", soon, confirmWait},
|
||
{"в работе, срок вышел", bitrix.SemanticProcess, late, confirmExpire},
|
||
{"подтвердили даже после срока — подтверждаем", bitrix.SemanticSuccess, late, confirmOK},
|
||
{"провал после начала приёма — в 1С не трогаем", bitrix.SemanticFailure, start.Add(time.Minute), confirmMissed},
|
||
{"никто не ответил до приёма — в 1С не трогаем", bitrix.SemanticProcess, start, confirmMissed},
|
||
}
|
||
for _, c := range cases {
|
||
if got := decideConfirm(c.semantic, created, start, c.now, cfg); got != c.want {
|
||
t.Errorf("%s: %v, ждали %v", c.name, got, c.want)
|
||
}
|
||
}
|
||
if got := decideConfirm(bitrix.SemanticProcess, created, start, late, storage.BookingSettings{}); got != confirmWait {
|
||
t.Errorf("автоотмена выключена: %v, ждали ожидание", got)
|
||
}
|
||
}
|
||
|
||
func TestLeadOnlyDraftAsksToBookManually(t *testing.T) {
|
||
it := storage.ConfirmItem{
|
||
Start: time.Date(2026, 10, 12, 15, 30, 0, 0, moscow), Patient: "Анна", Phone: "+7 928 000 00 00",
|
||
DoctorName: "Петрова <script>", OnecID: "",
|
||
}
|
||
d := leadOnlyDraft(it)
|
||
if !strings.HasPrefix(d.Title, "Заявка на запись: ") || d.SourceID != bitrixSiteSourceID || d.Phone != "+79280000000" {
|
||
t.Errorf("заголовок/источник/телефон: %+v", d)
|
||
}
|
||
for _, want := range []string{"НЕ закреплено", "запишите его в 1С вручную", "12.10.2026 в 15:30", "<script>"} {
|
||
if !strings.Contains(d.Comments, want) {
|
||
t.Errorf("в комментарии нет %q: %q", want, d.Comments)
|
||
}
|
||
}
|
||
if strings.Contains(d.Comments, "<script>") || strings.Contains(d.Comments, "№ записи в 1С") {
|
||
t.Errorf("лишнее в комментарии: %q", d.Comments)
|
||
}
|
||
}
|
||
|
||
func TestLeadDraftEscapesPatientInput(t *testing.T) {
|
||
it := storage.ConfirmItem{
|
||
Start: time.Date(2026, 10, 12, 15, 30, 0, 0, moscow), CreatedAt: time.Date(2026, 10, 9, 10, 0, 0, 0, moscow),
|
||
Patient: "Анна", Phone: "8 (928) 000-00-00", DoctorName: "Иванов <b>И.</b>", Branch: "ул. Ленина, 1",
|
||
ServiceName: "Консультация", OnecID: "A-17",
|
||
}
|
||
d := leadDraft(it, storage.DefaultBookingSettings)
|
||
if d.Phone != "+79280000000" {
|
||
t.Errorf("телефон не нормализован: %q", d.Phone)
|
||
}
|
||
if strings.Contains(d.Comments, "<b>") || !strings.Contains(d.Comments, "<b>") {
|
||
t.Errorf("HTML пациента/врача не экранирован: %q", d.Comments)
|
||
}
|
||
for _, want := range []string{"12.10.2026 в 15:30", "A-17", "Консультация", "Клиент записался", "отменится автоматически 10.10 в 10:00"} {
|
||
if !strings.Contains(d.Comments, want) {
|
||
t.Errorf("в комментарии нет %q: %q", want, d.Comments)
|
||
}
|
||
}
|
||
if d.SourceID != bitrixSiteSourceID || !strings.HasPrefix(d.Title, "Онлайн-запись: ") {
|
||
t.Errorf("источник/заголовок: %+v", d)
|
||
}
|
||
}
|
||
|
||
func TestCRMDraftsFormatting(t *testing.T) {
|
||
it := storage.ConfirmItem{
|
||
ID: 42,
|
||
Start: time.Date(2026, 10, 12, 15, 30, 0, 0, moscow),
|
||
CreatedAt: time.Date(2026, 10, 9, 10, 0, 0, 0, moscow),
|
||
Patient: "Иван Тестов",
|
||
Phone: "8 (928) 123-45-67",
|
||
DoctorName: "Доктор Тестов",
|
||
Branch: "ул. Ленина, 10",
|
||
ServiceName: "УЗИ брюшной полости",
|
||
OnecID: "DOC-123",
|
||
}
|
||
|
||
crmDraft := bookingConfirmToCRMDraft(it, storage.DefaultBookingSettings)
|
||
if crmDraft.LocalLeadId != "booking_42" {
|
||
t.Errorf("expected LocalLeadId booking_42, got %s", crmDraft.LocalLeadId)
|
||
}
|
||
if crmDraft.Phone != "+79281234567" {
|
||
t.Errorf("expected normalized phone +79281234567, got %s", crmDraft.Phone)
|
||
}
|
||
if crmDraft.SourceKey != "site_booking" {
|
||
t.Errorf("expected SourceKey site_booking, got %s", crmDraft.SourceKey)
|
||
}
|
||
for _, expectedText := range []string{"Онлайн-запись с сайта", "Доктор Тестов", "УЗИ брюшной полости", "DOC-123"} {
|
||
if !strings.Contains(crmDraft.Comment, expectedText) {
|
||
t.Errorf("expected comment to contain %q, got:\n%s", expectedText, crmDraft.Comment)
|
||
}
|
||
}
|
||
|
||
leadOnly := leadOnlyToCRMDraft(it)
|
||
if leadOnly.LocalLeadId != "booking_failed_42" {
|
||
t.Errorf("expected LocalLeadId booking_failed_42, got %s", leadOnly.LocalLeadId)
|
||
}
|
||
if leadOnly.SourceKey != "site_booking_fallback" {
|
||
t.Errorf("expected SourceKey site_booking_fallback, got %s", leadOnly.SourceKey)
|
||
}
|
||
if !strings.Contains(leadOnly.Comment, "запишите его в 1С вручную") {
|
||
t.Errorf("expected manual booking note in fallback comment, got:\n%s", leadOnly.Comment)
|
||
}
|
||
|
||
leadItem := storage.LeadCRMItem{
|
||
ID: 101,
|
||
FormTitle: "Запись на консультацию",
|
||
Name: "Мария",
|
||
Phone: "89285554433",
|
||
Email: "maria@example.com",
|
||
Service: "Консультация кардиолога",
|
||
Doctor: "Кардиолог К.К.",
|
||
Branch: "Махачкала, ул. Ляхова 22",
|
||
Message: "Хотелось бы попасть в субботу",
|
||
PageURL: "https://danamedical.ru/doctors/kardiolog",
|
||
UTM: map[string]string{
|
||
"utm_source": "yandex",
|
||
"utm_medium": "cpc",
|
||
},
|
||
CreatedAt: time.Date(2026, 10, 9, 11, 0, 0, 0, moscow),
|
||
}
|
||
|
||
siteDraft := leadItemToCRMDraft(leadItem)
|
||
if siteDraft.LocalLeadId != "site_lead_101" {
|
||
t.Errorf("expected site_lead_101, got %s", siteDraft.LocalLeadId)
|
||
}
|
||
if siteDraft.Phone != "+79285554433" {
|
||
t.Errorf("expected +79285554433, got %s", siteDraft.Phone)
|
||
}
|
||
if siteDraft.PagePath != "/doctors/kardiolog" {
|
||
t.Errorf("expected /doctors/kardiolog, got %s", siteDraft.PagePath)
|
||
}
|
||
if siteDraft.UtmSource != "yandex" || siteDraft.UtmMedium != "cpc" {
|
||
t.Errorf("UTM not properly preserved: %+v", siteDraft)
|
||
}
|
||
if !strings.Contains(siteDraft.Comment, "Форма: Запись на консультацию") {
|
||
t.Errorf("Comment missing form title: %s", siteDraft.Comment)
|
||
}
|
||
}
|