686 lines
27 KiB
Go
686 lines
27 KiB
Go
|
|
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 }
|