axe/internal/repository/recurring_test.go
2026-10-18 22:07:25 +03:00

686 lines
27 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 }