package repository import ( "errors" "slices" "testing" "time" "building-mgmt/internal/models" ) // Тесты правил повторяющихся заявок (миграции 0014/0016). Проверяем то, // что держится на SQL: созревание по периоду (anchor_at + k·period), // блокировка незакрытой заявкой, владение правилом и каскад при // удалении проекта. // TestNextDue — срок правила (чистая функция, без БД): наименьшая точка // сетки anchor + k·period строго после отправной точки; точка в // будущем — сама точка (пользователь выбрал первый запуск). func TestNextDue(t *testing.T) { anchor := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC) day := func(n int) time.Duration { return time.Duration(n) * 24 * time.Hour } cases := []struct { name string anchor time.Time period int ref time.Time want time.Time }{ {"отправная точка раньше сетки", anchor, 7, anchor.Add(-day(12)), anchor}, {"ref ровно на точке — следующий период", anchor, 7, anchor, anchor.Add(day(7))}, {"через секунду после точки", anchor, 7, anchor.Add(time.Second), anchor.Add(day(7))}, {"между точками — ближайшая будущая", anchor, 7, anchor.Add(day(2)), anchor.Add(day(7))}, {"пропущено много периодов", anchor, 30, anchor.Add(day(100)), anchor.Add(day(120))}, {"недельный период (14 дней)", anchor, 14, anchor.Add(day(16)), anchor.Add(day(28))}, {"годовой период (365 дней)", anchor, 365, anchor.Add(day(370)), anchor.Add(day(730))}, {"битый нулевой период не роняет расчёт", anchor, 0, anchor, anchor.Add(day(1))}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { got := nextDue(tc.anchor, tc.period, tc.ref) if !got.Equal(tc.want) { t.Errorf("nextDue(%v, %d, %v) = %v, want %v", tc.anchor, tc.period, tc.ref, got, tc.want) } }) } } // TestRecurringLifecycle — сквозной сценарий планировщика: правило не // созрело до срока, создаёт заявку по созревании, ждёт закрытия // прошлой и создаёт следующую, когда её закрыли. func TestRecurringLifecycle(t *testing.T) { q, ctx := newTestQuerier(t) repo := NewRecurringRepo(q) tickets := NewTicketRepo(q) client := createProfile(t, ctx, q, "client") admin := createProfile(t, ctx, q, "admin") projectID, objectID, address := createProjectObject(t, ctx, q, client.ID) rule := &models.RecurringRule{ ClientID: client.ID, ProjectID: projectID, ObjectID: objectID, Category: "climate", Description: "Осмотр вентиляции", Priority: "normal", Floor: "3", AnchorAt: time.Now().Add(-40 * 24 * time.Hour).UTC().Truncate(time.Second), PeriodDays: 30, IsActive: true, CreatedBy: admin.ID, } if err := repo.CreateRule(ctx, rule); err != nil { t.Fatalf("create rule: %v", err) } if rule.ID == 0 || rule.CreatedAt.IsZero() { t.Fatalf("rule not filled: id=%d created_at=%v", rule.ID, rule.CreatedAt) } // --- 1. Свежее правило не созрело: первая точка сетки ещё впереди --- created, err := repo.RunDueRules(ctx, time.Now()) if err != nil { t.Fatalf("run (fresh rule): %v", err) } if n := len(dueFor(created, rule.ID)); n != 0 { t.Fatalf("fresh rule created %d tickets, want 0", n) } // --- 2. Подвигаем created_at в прошлое — правило созрело --- if _, err := q.Exec(ctx, `UPDATE recurring_rules SET created_at = now() - make_interval(years => 2) WHERE id = $1`, rule.ID); err != nil { t.Fatalf("backdate created_at: %v", err) } created, err = repo.RunDueRules(ctx, time.Now()) if err != nil { t.Fatalf("run (due rule): %v", err) } created = dueFor(created, rule.ID) if len(created) != 1 { t.Fatalf("due rule created %d tickets, want 1", len(created)) } got := created[0] if got.ClientID != client.ID || got.Address != address || got.Category != "climate" || got.Description != "Осмотр вентиляции" || got.Priority != "normal" { t.Errorf("DueRun = %+v, want client/address/category from rule", got) } // Заявка создана с адресом-снимком, статусом new и журналом created. tk, err := tickets.GetByID(ctx, got.TicketID) if err != nil { t.Fatalf("get created ticket: %v", err) } if tk.Status != "new" || tk.Address != address || tk.Floor != "3" || tk.ProjectID != projectID || tk.ObjectID != objectID { t.Errorf("ticket = status %q floor %q project %d object %d, want new rule copy", tk.Status, tk.Floor, tk.ProjectID, tk.ObjectID) } events, _, err := tickets.ListEvents(ctx, got.TicketID, Page{Limit: MaxLimit}) if err != nil { t.Fatalf("events: %v", err) } if len(events) != 1 || events[0].Action != "created" || events[0].ActorID != admin.ID { t.Errorf("events = %+v, want one created by rule author", events) } // last_run_at сдвинулся, история записана. rule, err = repo.GetRule(ctx, rule.ID, "") if err != nil { t.Fatalf("get rule: %v", err) } if rule.LastRunAt == nil { t.Fatal("last_run_at is nil after run, want set") } runs, total, err := repo.ListRuns(ctx, rule.ID, Page{Limit: MaxLimit}) if err != nil { t.Fatalf("list runs: %v", err) } if total != 1 || len(runs) != 1 || runs[0].TicketID == nil || *runs[0].TicketID != got.TicketID { t.Errorf("runs = %+v (total %d), want one row with ticket %d", runs, total, got.TicketID) } // --- 3. Повторный прогон сразу — срок от запуска не наступил --- if created, err = repo.RunDueRules(ctx, time.Now()); err != nil { t.Fatalf("immediate re-run: %v", err) } if n := len(dueFor(created, rule.ID)); n != 0 { t.Fatalf("immediate re-run created %d tickets, want 0", n) } // --- 4. Срок наступил, но прошлая заявка открыта — новую не создаём --- if _, err := q.Exec(ctx, `UPDATE recurring_rules SET last_run_at = now() - make_interval(years => 2) WHERE id = $1`, rule.ID); err != nil { t.Fatalf("backdate last_run_at: %v", err) } created, err = repo.RunDueRules(ctx, time.Now()) if err != nil { t.Fatalf("run (open ticket): %v", err) } if n := len(dueFor(created, rule.ID)); n != 0 { t.Fatalf("open ticket blocked nothing: created %d, want 0", n) } // Срок не сдвинулся — после закрытия заявка создастся сразу. rule, err = repo.GetRule(ctx, rule.ID, "") if err != nil { t.Fatalf("get rule: %v", err) } if rule.LastRunAt == nil || !rule.LastRunAt.Before(time.Now().Add(-3*24*time.Hour)) { t.Errorf("last_run_at = %v, want still backdated (skip must not advance)", rule.LastRunAt) } // --- 5. Закрыли — следующий тик создаёт следующую заявку --- if err := tickets.Close(ctx, got.TicketID, client.ID, "осмотр перенесён", []string{"new"}); err != nil { t.Fatalf("close ticket: %v", err) } created, err = repo.RunDueRules(ctx, time.Now()) if err != nil { t.Fatalf("run (closed ticket): %v", err) } created = dueFor(created, rule.ID) if len(created) != 1 { t.Fatalf("after close created %d tickets, want 1", len(created)) } if created[0].TicketID == got.TicketID { t.Error("second run returned the same ticket id") } _, total, err = repo.ListRuns(ctx, rule.ID, Page{Limit: MaxLimit}) if err != nil { t.Fatalf("list runs: %v", err) } if total != 2 { t.Errorf("runs total = %d, want 2", total) } } // TestRecurringOwnership — чужое правило неотличимо от несуществующего, // админская выборка видит всё, клиент — только своё. func TestRecurringOwnership(t *testing.T) { q, ctx := newTestQuerier(t) repo := NewRecurringRepo(q) owner := createProfile(t, ctx, q, "client") stranger := createProfile(t, ctx, q, "client") mk := func(ownerID string) *models.RecurringRule { projectID, objectID, _ := createProjectObject(t, ctx, q, ownerID) rule := &models.RecurringRule{ ClientID: ownerID, ProjectID: projectID, ObjectID: objectID, Category: "climate", Description: "Осмотр вентиляции", Priority: "normal", AnchorAt: time.Now().Add(-40 * 24 * time.Hour), PeriodDays: 30, IsActive: true, } if err := repo.CreateRule(ctx, rule); err != nil { t.Fatalf("create rule: %v", err) } return rule } mine := mk(owner.ID) theirs := mk(stranger.ID) // Чтение: своё — находит, чужое — нет, админская выборка — находит. if _, err := repo.GetRule(ctx, mine.ID, owner.ID); err != nil { t.Errorf("owner access: err = %v, want nil", err) } if _, err := repo.GetRule(ctx, mine.ID, stranger.ID); !errors.Is(err, ErrNotFound) { t.Errorf("stranger access: err = %v, want ErrNotFound", err) } if _, err := repo.GetRule(ctx, mine.ID, ""); err != nil { t.Errorf("admin access: err = %v, want nil", err) } // Списки: клиент видит только своё, админ — оба. own, total, err := repo.ListRules(ctx, owner.ID, 0, Page{Limit: MaxLimit}) if err != nil { t.Fatalf("list own: %v", err) } if len(own) != 1 || own[0].ID != mine.ID || total != 1 { t.Errorf("owner list = %d rules (total %d), want only mine", len(own), total) } all, total, err := repo.ListRules(ctx, "", 0, Page{Limit: MaxLimit}) if err != nil { t.Fatalf("list all: %v", err) } // Админская выборка видит всё, включая правила из живой dev-БД, // поэтому проверяем присутствие своих, а не точное число строк. seen := map[int64]bool{} for _, r := range all { seen[r.ID] = true } if total < 2 || !seen[mine.ID] || !seen[theirs.ID] { t.Errorf("admin list total=%d seen(mine)=%v seen(theirs)=%v, want both present", total, seen[mine.ID], seen[theirs.ID]) } // Правка и удаление чужого — 404, своего — проходят. if err := repo.UpdateRule(ctx, mine.ID, stranger.ID, RulePatch{IsActive: boolPtr(false)}); !errors.Is(err, ErrNotFound) { t.Errorf("stranger update: err = %v, want ErrNotFound", err) } if err := repo.DeleteRule(ctx, theirs.ID, owner.ID); !errors.Is(err, ErrNotFound) { t.Errorf("stranger delete: err = %v, want ErrNotFound", err) } if err := repo.UpdateRule(ctx, mine.ID, owner.ID, RulePatch{IsActive: boolPtr(false)}); err != nil { t.Errorf("owner update: err = %v, want nil", err) } rule, err := repo.GetRule(ctx, mine.ID, "") if err != nil { t.Fatalf("get after update: %v", err) } if rule.IsActive { t.Error("is_active = true after disabling") } // Выключенное правило планировщик не трогает. if _, err := q.Exec(ctx, `UPDATE recurring_rules SET created_at = now() - make_interval(years => 2) WHERE id = $1`, mine.ID); err != nil { t.Fatalf("backdate: %v", err) } created, err := repo.RunDueRules(ctx, time.Now()) if err != nil { t.Fatalf("run (disabled): %v", err) } if n := len(dueFor(created, mine.ID)); n != 0 { t.Errorf("disabled rule created %d tickets, want 0", n) } } // TestRecurringProjectCascade — правило живёт в проекте: удаление // проекта (каскад 0013/0014) уносит правила и историю срабатываний, // а заявка остаётся только если её не удалили вместе с проектом. func TestRecurringProjectCascade(t *testing.T) { q, ctx := newTestQuerier(t) repo := NewRecurringRepo(q) // Снимок до создания: после каскада счётчик должен вернуться к нему — // живая dev-БД свои правила оставляет. _, before, err := repo.ListRules(ctx, "", 0, Page{Limit: MaxLimit}) if err != nil { t.Fatalf("list before: %v", err) } client := createProfile(t, ctx, q, "client") projectID, objectID, _ := createProjectObject(t, ctx, q, client.ID) rule := &models.RecurringRule{ ClientID: client.ID, ProjectID: projectID, ObjectID: objectID, Category: "climate", Description: "Осмотр вентиляции", Priority: "normal", AnchorAt: time.Now().Add(-40 * 24 * time.Hour), PeriodDays: 30, IsActive: true, } if err := repo.CreateRule(ctx, rule); err != nil { t.Fatalf("create rule: %v", err) } // Созревшая заявка — чтобы история срабатывания была заполнена. if _, err := q.Exec(ctx, `UPDATE recurring_rules SET created_at = now() - make_interval(years => 2) WHERE id = $1`, rule.ID); err != nil { t.Fatalf("backdate: %v", err) } created, err := repo.RunDueRules(ctx, time.Now()) if err != nil { t.Fatalf("run: %v", err) } if n := len(dueFor(created, rule.ID)); n != 1 { t.Fatalf("run = %d, %v; want 1, nil", n, err) } // Удаление проекта — тем же путём, что у админа (каскад 0013). if _, err := NewProjectRepo(q).DeleteProject(ctx, projectID); err != nil { t.Fatalf("delete project: %v", err) } if _, err := repo.GetRule(ctx, rule.ID, ""); !errors.Is(err, ErrNotFound) { t.Errorf("rule after project delete: err = %v, want ErrNotFound", err) } rules, total, err := repo.ListRules(ctx, "", 0, Page{Limit: MaxLimit}) if err != nil { t.Fatalf("list: %v", err) } if len(rules) != int(before) || total != before { t.Errorf("rules after cascade = %d (total %d), want back to %d", len(rules), total, before) } } // TestRecurringUpdatePatch — частичное обновление правила: применяются // только переданные поля, остальные не трогаются; пустой патч — no-op. func TestRecurringUpdatePatch(t *testing.T) { q, ctx := newTestQuerier(t) repo := NewRecurringRepo(q) client := createProfile(t, ctx, q, "client") projectID, objectID, _ := createProjectObject(t, ctx, q, client.ID) anchor := time.Now().Add(-40 * 24 * time.Hour).UTC().Truncate(time.Second) rule := &models.RecurringRule{ ClientID: client.ID, ProjectID: projectID, ObjectID: objectID, Category: "climate", Description: "Осмотр вентиляции", Priority: "normal", Floor: "3", AnchorAt: anchor, PeriodDays: 30, IsActive: true, } if err := repo.CreateRule(ctx, rule); err != nil { t.Fatalf("create rule: %v", err) } // Меняем описание, расписание и приоритет — категория и этаж должны // уцелеть, иначе «частичное» оказалось бы заменой. newAnchor := time.Now().Add(72 * time.Hour).UTC().Truncate(time.Second) if err := repo.UpdateRule(ctx, rule.ID, client.ID, RulePatch{ Description: strPtr("Осмотр вентиляции и фильтров"), AnchorAt: timePtr(newAnchor), PeriodDays: intPtr(14), Priority: strPtr("high"), }); err != nil { t.Fatalf("patch: %v", err) } got, err := repo.GetRule(ctx, rule.ID, "") if err != nil { t.Fatalf("get: %v", err) } if got.Description != "Осмотр вентиляции и фильтров" || !got.AnchorAt.Equal(newAnchor) || got.PeriodDays != 14 || got.Priority != "high" { t.Errorf("patched fields = %q/%v/%d/%q, want new anchor/period and values", got.Description, got.AnchorAt, got.PeriodDays, got.Priority) } if got.Category != "climate" || got.Floor != "3" || !got.IsActive { t.Errorf("untouched fields = %q/%q/active=%v, want rule copy", got.Category, got.Floor, got.IsActive) } // Выключение — только is_active, шаблон не страдает. if err := repo.UpdateRule(ctx, rule.ID, client.ID, RulePatch{IsActive: boolPtr(false)}); err != nil { t.Fatalf("disable: %v", err) } got, err = repo.GetRule(ctx, rule.ID, "") if err != nil { t.Fatalf("get: %v", err) } if got.IsActive || !got.AnchorAt.Equal(newAnchor) || got.PeriodDays != 14 { t.Errorf("disable changed more than is_active: active=%v schedule=%v/%d", got.IsActive, got.AnchorAt, got.PeriodDays) } // Пустой этаж — NULL (как при создании пустой строки). if err := repo.UpdateRule(ctx, rule.ID, client.ID, RulePatch{Floor: strPtr("")}); err != nil { t.Fatalf("clear floor: %v", err) } if got, err = repo.GetRule(ctx, rule.ID, ""); err != nil || got.Floor != "" { t.Errorf("floor = %q (err %v), want empty", got.Floor, err) } } // TestRecurringRunNow — ручной запуск: обходит ожидание срока, но не // рубежи «выключено» и «есть открытая заявка»; чужое правило — // ErrNotFound; после запуска отсчёт сдвинулся и фоновый тик дубля не делает. func TestRecurringRunNow(t *testing.T) { q, ctx := newTestQuerier(t) repo := NewRecurringRepo(q) tickets := NewTicketRepo(q) owner := createProfile(t, ctx, q, "client") stranger := createProfile(t, ctx, q, "client") projectID, objectID, _ := createProjectObject(t, ctx, q, owner.ID) rule := &models.RecurringRule{ ClientID: owner.ID, ProjectID: projectID, ObjectID: objectID, Category: "plumbing", Description: "Промывка системы отопления", Priority: "normal", AnchorAt: time.Now().Add(-40 * 24 * time.Hour), PeriodDays: 30, IsActive: true, } if err := repo.CreateRule(ctx, rule); err != nil { t.Fatalf("create rule: %v", err) } // Свежее правило: планировщик не трогает (срок не наступил)... if created, err := repo.RunDueRules(ctx, time.Now()); err != nil || len(dueFor(created, rule.ID)) != 0 { t.Fatalf("fresh rule run = %v, %v; want nothing", created, err) } // ...а ручной запуск создаёт заявку сразу. run, err := repo.RunRuleNow(ctx, rule.ID, "", time.Now()) if err != nil { t.Fatalf("run now: %v", err) } if run.TicketID == 0 || run.RuleID != rule.ID { t.Fatalf("run = %+v, want new ticket for rule %d", run, rule.ID) } after, err := repo.GetRule(ctx, rule.ID, "") if err != nil { t.Fatalf("get rule: %v", err) } if after.LastRunAt == nil { t.Fatal("last_run_at is nil after manual run, want set") } // Пока заявка открыта — повторный запуск это конфликт, не дубль. if _, err := repo.RunRuleNow(ctx, rule.ID, "", time.Now()); !errors.Is(err, ErrConflict) || !errors.Is(err, ErrRuleOpen) { t.Errorf("second run: err = %v, want ErrConflict wrapping ErrRuleOpen", err) } // Фоновый тик сразу после ручного запуска тоже дубль не делает // (заявка открыта), а позже срок от ручного запуска не наступил. if created, err := repo.RunDueRules(ctx, time.Now()); err != nil || len(dueFor(created, rule.ID)) != 0 { t.Errorf("scheduler right after run now = %v, %v; want nothing", created, err) } // Закрыли — ручной запуск снова проходит. if err := tickets.Close(ctx, run.TicketID, owner.ID, "сделано", []string{"new"}); err != nil { t.Fatalf("close ticket: %v", err) } if _, err := repo.RunRuleNow(ctx, rule.ID, owner.ID, time.Now()); err != nil { t.Errorf("run now after close: err = %v, want nil", err) } // Выключенное правило — конфликт с причиной; чужое — ErrNotFound. if err := repo.UpdateRule(ctx, rule.ID, "", RulePatch{IsActive: boolPtr(false)}); err != nil { t.Fatalf("disable: %v", err) } if _, err := repo.RunRuleNow(ctx, rule.ID, "", time.Now()); !errors.Is(err, ErrRuleNotActive) { t.Errorf("run disabled: err = %v, want ErrRuleNotActive", err) } if _, err := repo.RunRuleNow(ctx, rule.ID, stranger.ID, time.Now()); !errors.Is(err, ErrNotFound) { t.Errorf("run stranger: err = %v, want ErrNotFound", err) } } // TestRecurringOrphanRunsCleanup — автоочистка заявки уносит и строку // её истории: у удалённой заявки recurring_runs остаётся сиротой // (ticket_id → SET NULL), DeleteExpired чистит такие строки тем же // прогоном. func TestRecurringOrphanRunsCleanup(t *testing.T) { q, ctx := newTestQuerier(t) repo := NewRecurringRepo(q) tickets := NewTicketRepo(q) client := createProfile(t, ctx, q, "client") projectID, objectID, _ := createProjectObject(t, ctx, q, client.ID) rule := &models.RecurringRule{ ClientID: client.ID, ProjectID: projectID, ObjectID: objectID, Category: "elevator", Description: "Плановый осмотр лифта", Priority: "normal", AnchorAt: time.Now().Add(-40 * 24 * time.Hour), PeriodDays: 30, IsActive: true, } if err := repo.CreateRule(ctx, rule); err != nil { t.Fatalf("create rule: %v", err) } if _, err := q.Exec(ctx, `UPDATE recurring_rules SET created_at = now() - make_interval(years => 2) WHERE id = $1`, rule.ID); err != nil { t.Fatalf("backdate: %v", err) } created, err := repo.RunDueRules(ctx, time.Now()) if err != nil { t.Fatalf("run: %v", err) } due := dueFor(created, rule.ID) if len(due) != 1 { t.Fatalf("run created %d tickets, want 1", len(due)) } // История есть; закрываем заявку и увозим closed_at за срок retention, // чтобы автоочистка её взяла. if _, _, err := repo.ListRuns(ctx, rule.ID, Page{Limit: MaxLimit}); err != nil { t.Fatalf("runs before cleanup: %v", err) } if err := tickets.Close(ctx, due[0].TicketID, client.ID, "осмотр выполнен", []string{"new"}); err != nil { t.Fatalf("close ticket: %v", err) } if _, err := q.Exec(ctx, `UPDATE tickets SET closed_at = now() - interval '61 days' WHERE id = $1`, due[0].TicketID); err != nil { t.Fatalf("backdate closed_at: %v", err) } ids, _, err := tickets.DeleteExpired(ctx, time.Now().Add(-60*24*time.Hour)) if err != nil { t.Fatalf("delete expired: %v", err) } if !slices.Contains(ids, due[0].TicketID) { t.Errorf("deleted %v, want our ticket %d among them", ids, due[0].TicketID) } // Строка истории ушла вместе с заявкой — сироты не осталось. runs, total, err := repo.ListRuns(ctx, rule.ID, Page{Limit: MaxLimit}) if err != nil { t.Fatalf("runs after cleanup: %v", err) } if total != 0 || len(runs) != 0 { t.Errorf("runs after cleanup = %d (total %d), want 0", len(runs), total) } // И самой заявки нет. if _, err := tickets.GetByID(ctx, due[0].TicketID); !errors.Is(err, ErrNotFound) { t.Errorf("ticket after cleanup: err = %v, want ErrNotFound", err) } } // TestTicketCreateWithRule — чекбокс «Повторяющаяся заявка» в форме // новой заявки: заявка и правило создаются одной операцией, связь // пишется в recurring_runs (за неё цепляется блок на странице заявки // и история), открытая первая заявка блокирует автозапуск. func TestTicketCreateWithRule(t *testing.T) { q, ctx := newTestQuerier(t) repo := NewRecurringRepo(q) tickets := NewTicketRepo(q) client := createProfile(t, ctx, q, "client") projectID, objectID, address := createProjectObject(t, ctx, q, client.ID) tk := &models.Ticket{ ClientID: client.ID, ProjectID: projectID, ObjectID: objectID, Address: address, Floor: "2", Category: "plumbing", Description: "Промывка радиаторов", Priority: "normal", } anchor := time.Now().Add(-14 * 24 * time.Hour).UTC().Truncate(time.Second) ruleID, err := tickets.CreateWithRule(ctx, tk, Schedule{AnchorAt: anchor, PeriodDays: 14}, client.ID) if err != nil { t.Fatalf("create with rule: %v", err) } if tk.ID == 0 || ruleID == 0 { t.Fatalf("ids not filled: ticket=%d rule=%d", tk.ID, ruleID) } // Заявка видит своё правило — блок на странице имеет за что // зацепиться. got, err := tickets.GetByID(ctx, tk.ID) if err != nil { t.Fatalf("get: %v", err) } if got.Recurring == nil { t.Fatal("ticket recurring is nil, want rule link") } if got.Recurring.RuleID != ruleID || got.Recurring.PeriodDays != 14 || !got.Recurring.AnchorAt.Equal(anchor) || !got.Recurring.IsActive { t.Errorf("recurring = %+v, want rule %d, 14 days from %v, active", got.Recurring, ruleID, anchor) } if got.Recurring.NextRunAt == nil { t.Error("next_run_at is nil, want computed at read time") } // Правило — шаблон самой заявки. rule, err := repo.GetRule(ctx, ruleID, "") if err != nil { t.Fatalf("get rule: %v", err) } if rule.ClientID != client.ID || rule.ProjectID != projectID || rule.ObjectID != objectID || rule.Floor != "2" || rule.Category != "plumbing" || rule.Description != "Промывка радиаторов" { t.Errorf("rule = %+v, want ticket template copy", rule) } // История содержит «семенную» заявку. runs, total, err := repo.ListRuns(ctx, ruleID, Page{Limit: MaxLimit}) if err != nil { t.Fatalf("runs: %v", err) } if total != 1 || len(runs) != 1 || runs[0].TicketID == nil || *runs[0].TicketID != tk.ID { t.Errorf("runs = %+v (total %d), want seed ticket %d", runs, total, tk.ID) } // Срок наступил, но первая заявка открыта — автозапуск ждёт. if _, err := q.Exec(ctx, `UPDATE recurring_rules SET created_at = now() - make_interval(years => 2) WHERE id = $1`, ruleID); err != nil { t.Fatalf("backdate: %v", err) } created, err := repo.RunDueRules(ctx, time.Now()) if err != nil { t.Fatalf("run due: %v", err) } if n := len(dueFor(created, ruleID)); n != 0 { t.Errorf("open seed ticket created %d duplicates, want 0", n) } // Обычная заявка без чекбокса — без связи с правилом. plain := &models.Ticket{ ClientID: client.ID, ProjectID: projectID, ObjectID: objectID, Address: address, Category: "other", Description: "Прочее", Priority: "normal", } if err := tickets.Create(ctx, plain); err != nil { t.Fatalf("create plain: %v", err) } plainGot, err := tickets.GetByID(ctx, plain.ID) if err != nil { t.Fatalf("get plain: %v", err) } if plainGot.Recurring != nil { t.Errorf("plain ticket recurring = %+v, want nil", plainGot.Recurring) } } // dueFor — срабатывания конкретного правила. База общая с dev-сервером: // в общем прогоне RunDueRules могут созреть и чужие правила, свои // проверки считаем только по своему rule_id. func dueFor(created []DueRun, ruleID int64) []DueRun { var out []DueRun for _, d := range created { if d.RuleID == ruleID { out = append(out, d) } } return out } // boolPtr — короткая запись указателя для RulePatch. func boolPtr(b bool) *bool { return &b } // strPtr / intPtr / timePtr — то же для строк, чисел и дат в RulePatch. func strPtr(s string) *string { return &s } func intPtr(n int) *int { return &n } func timePtr(t time.Time) *time.Time { return &t }