1014 lines
39 KiB
Go
1014 lines
39 KiB
Go
package repository
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"os"
|
||
"slices"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/google/uuid"
|
||
"github.com/jackc/pgx/v5/pgxpool"
|
||
"github.com/joho/godotenv"
|
||
|
||
"building-mgmt/internal/models"
|
||
)
|
||
|
||
// newTestQuerier поднимает транзакцию поверх DATABASE_URL и откатывает её
|
||
// в конце теста: ограничения и статусная машина проверяются на реальной БД,
|
||
// но данные в неё не остаются. Без DATABASE_URL тест пропускается.
|
||
func newTestQuerier(t *testing.T) (Querier, context.Context) {
|
||
t.Helper()
|
||
|
||
_ = godotenv.Load("../../.env")
|
||
dsn := os.Getenv("DATABASE_URL")
|
||
if dsn == "" {
|
||
t.Skip("DATABASE_URL is not set — integration test skipped")
|
||
}
|
||
|
||
ctx := context.Background()
|
||
pool, err := pgxpool.New(ctx, dsn)
|
||
if err != nil {
|
||
t.Skipf("pool: %v", err)
|
||
}
|
||
if err := pool.Ping(ctx); err != nil {
|
||
pool.Close()
|
||
t.Skipf("database is not reachable: %v", err)
|
||
}
|
||
t.Cleanup(pool.Close)
|
||
|
||
tx, err := pool.Begin(ctx)
|
||
if err != nil {
|
||
t.Fatalf("begin tx: %v", err)
|
||
}
|
||
t.Cleanup(func() { _ = tx.Rollback(ctx) })
|
||
|
||
return tx, ctx
|
||
}
|
||
|
||
func createProfile(t *testing.T, ctx context.Context, q Querier, role string) *models.Profile {
|
||
t.Helper()
|
||
|
||
p := &models.Profile{
|
||
ID: uuid.NewString(),
|
||
Email: fmt.Sprintf("%s-%s@example.test", role, uuid.NewString()),
|
||
PasswordHash: "test-hash",
|
||
Name: role + "-test",
|
||
Role: role,
|
||
IsActive: true,
|
||
}
|
||
if err := NewProfileRepo(q).Create(ctx, p); err != nil {
|
||
t.Fatalf("create %s profile: %v", role, err)
|
||
}
|
||
// Тестовый профиль — «рабочий», как после создания админом или
|
||
// подтверждения (0010): приглашение и вход неподтверждённых не
|
||
// пускают. Кто проверяет неподтверждённого — создаёт и подтверждает
|
||
// сам (см. profile_test).
|
||
if _, err := q.Exec(ctx,
|
||
`UPDATE profiles SET confirmed_at = now() WHERE id = $1`, p.ID); err != nil {
|
||
t.Fatalf("confirm %s profile: %v", role, err)
|
||
}
|
||
now := time.Now()
|
||
p.ConfirmedAt = &now
|
||
return p
|
||
}
|
||
|
||
func mustGetTicket(t *testing.T, ctx context.Context, repo *TicketRepo, id int64) *models.Ticket {
|
||
t.Helper()
|
||
got, err := repo.GetByID(ctx, id)
|
||
if err != nil {
|
||
t.Fatalf("get ticket %d: %v", id, err)
|
||
}
|
||
return got
|
||
}
|
||
|
||
// createProjectObject — проект и объект для заявок: схема 0004 требует,
|
||
// чтобы заявка жила в объекте проекта своего клиента.
|
||
func createProjectObject(t *testing.T, ctx context.Context, q Querier, clientID string) (projectID, objectID int64, address string) {
|
||
t.Helper()
|
||
|
||
projects := NewProjectRepo(q)
|
||
suffix := uuid.NewString()[:8]
|
||
|
||
p := &models.Project{ClientID: clientID, Name: "project-" + suffix, IsActive: true}
|
||
if err := projects.CreateProject(ctx, p); err != nil {
|
||
t.Fatalf("create project: %v", err)
|
||
}
|
||
o := &models.Object{
|
||
ProjectID: p.ID,
|
||
Name: "object-" + suffix,
|
||
Address: "ул. Тестовая, 1",
|
||
IsActive: true,
|
||
}
|
||
if err := projects.CreateObject(ctx, o); err != nil {
|
||
t.Fatalf("create object: %v", err)
|
||
}
|
||
return p.ID, o.ID, o.Address
|
||
}
|
||
|
||
// TestTicketStateMachine — сквозной сценарий жизненного цикла заявки:
|
||
// new → assigned → in_progress → done, с проверкой всех запрещённых переходов.
|
||
func TestTicketStateMachine(t *testing.T) {
|
||
q, ctx := newTestQuerier(t)
|
||
tickets := NewTicketRepo(q)
|
||
|
||
client := createProfile(t, ctx, q, "client")
|
||
worker := createProfile(t, ctx, q, "worker")
|
||
otherWorker := createProfile(t, ctx, q, "worker")
|
||
admin := createProfile(t, ctx, q, "admin")
|
||
|
||
projectID, objectID, address := createProjectObject(t, ctx, q, client.ID)
|
||
// Назначить можно только принявшего приглашение мастера проекта.
|
||
joinProject(t, ctx, NewProjectRepo(q), projectID, worker.ID, admin.ID)
|
||
|
||
// --- 1. Создание: статус new, мастер не назначен ---
|
||
ticket := &models.Ticket{
|
||
ClientID: client.ID,
|
||
ProjectID: projectID,
|
||
ObjectID: objectID,
|
||
Address: address,
|
||
Floor: "3",
|
||
Category: "plumbing",
|
||
Description: "течёт кран на кухне",
|
||
Priority: "high",
|
||
}
|
||
if err := tickets.Create(ctx, ticket); err != nil {
|
||
t.Fatalf("create ticket: %v", err)
|
||
}
|
||
if ticket.Status != "new" {
|
||
t.Errorf("status after create = %q, want %q", ticket.Status, "new")
|
||
}
|
||
if ticket.WorkerID != nil {
|
||
t.Errorf("worker_id after create = %v, want nil", *ticket.WorkerID)
|
||
}
|
||
if ticket.CreatedAt.IsZero() {
|
||
t.Error("created_at is zero")
|
||
}
|
||
|
||
// --- 2. Клиент видит свою заявку, JOIN подтянул имя ---
|
||
created := mustGetTicket(t, ctx, tickets, ticket.ID)
|
||
if created.ClientName != client.Name {
|
||
t.Errorf("client_name = %q, want %q", created.ClientName, client.Name)
|
||
}
|
||
if created.WorkerName != "" {
|
||
t.Errorf("worker_name = %q, want empty", created.WorkerName)
|
||
}
|
||
|
||
// --- 3. Админ назначает мастера ---
|
||
if err := tickets.Assign(ctx, ticket.ID, worker.ID, admin.ID); err != nil {
|
||
t.Fatalf("assign: %v", err)
|
||
}
|
||
assigned := mustGetTicket(t, ctx, tickets, ticket.ID)
|
||
if assigned.Status != "assigned" {
|
||
t.Errorf("status = %q, want %q", assigned.Status, "assigned")
|
||
}
|
||
if assigned.WorkerID == nil || *assigned.WorkerID != worker.ID {
|
||
t.Errorf("worker_id = %v, want %s", assigned.WorkerID, worker.ID)
|
||
}
|
||
if assigned.AssignedAt == nil {
|
||
t.Error("assigned_at is nil after assign")
|
||
}
|
||
if assigned.WorkerName != worker.Name {
|
||
t.Errorf("worker_name = %q, want %q", assigned.WorkerName, worker.Name)
|
||
}
|
||
|
||
// --- 4. Повторное назначение запрещено: заявка уже не new ---
|
||
err := tickets.Assign(ctx, ticket.ID, otherWorker.ID, admin.ID)
|
||
if !errors.Is(err, ErrConflict) {
|
||
t.Errorf("re-assign: err = %v, want ErrConflict", err)
|
||
}
|
||
|
||
// --- 5. Взять в работу может только назначенный мастер ---
|
||
err = tickets.Start(ctx, ticket.ID, otherWorker.ID)
|
||
if !errors.Is(err, ErrConflict) {
|
||
t.Errorf("start by another worker: err = %v, want ErrConflict", err)
|
||
}
|
||
|
||
// --- 6. Назначенный мастер берёт заявку ---
|
||
if err := tickets.Start(ctx, ticket.ID, worker.ID); err != nil {
|
||
t.Fatalf("start: %v", err)
|
||
}
|
||
started := mustGetTicket(t, ctx, tickets, ticket.ID)
|
||
if started.Status != "in_progress" {
|
||
t.Errorf("status = %q, want %q", started.Status, "in_progress")
|
||
}
|
||
if started.StartedAt == nil {
|
||
t.Error("started_at is nil after start")
|
||
}
|
||
|
||
// --- 7. Повторный start идемпотентен (заявка уже в работе) ---
|
||
if err := tickets.Start(ctx, ticket.ID, worker.ID); err != nil {
|
||
t.Errorf("second start: %v", err)
|
||
}
|
||
|
||
// --- 8. Завершить может только назначенный мастер ---
|
||
err = tickets.Done(ctx, ticket.ID, otherWorker.ID)
|
||
if !errors.Is(err, ErrConflict) {
|
||
t.Errorf("done by another worker: err = %v, want ErrConflict", err)
|
||
}
|
||
|
||
// --- 9. Мастер завершает заявку ---
|
||
if err := tickets.Done(ctx, ticket.ID, worker.ID); err != nil {
|
||
t.Fatalf("done: %v", err)
|
||
}
|
||
finished := mustGetTicket(t, ctx, tickets, ticket.ID)
|
||
if finished.Status != "done" {
|
||
t.Errorf("status = %q, want %q", finished.Status, "done")
|
||
}
|
||
if finished.DoneAt == nil {
|
||
t.Error("done_at is nil after done")
|
||
}
|
||
|
||
// --- 10. Из done назад и вперед по циклу нельзя ---
|
||
if err := tickets.Done(ctx, ticket.ID, worker.ID); !errors.Is(err, ErrConflict) {
|
||
t.Errorf("second done: err = %v, want ErrConflict", err)
|
||
}
|
||
if err := tickets.Start(ctx, ticket.ID, worker.ID); !errors.Is(err, ErrConflict) {
|
||
t.Errorf("start after done: err = %v, want ErrConflict", err)
|
||
}
|
||
if err := tickets.Assign(ctx, ticket.ID, otherWorker.ID, admin.ID); !errors.Is(err, ErrConflict) {
|
||
t.Errorf("assign after done: err = %v, want ErrConflict", err)
|
||
}
|
||
|
||
// --- 11. Несуществующая заявка — ErrNotFound, а не конфликт ---
|
||
if err := tickets.Start(ctx, 999999999, worker.ID); !errors.Is(err, ErrNotFound) {
|
||
t.Errorf("start missing ticket: err = %v, want ErrNotFound", err)
|
||
}
|
||
if _, err := tickets.GetByID(ctx, 999999999); !errors.Is(err, ErrNotFound) {
|
||
t.Errorf("get missing ticket: err = %v, want ErrNotFound", err)
|
||
}
|
||
}
|
||
|
||
// TestTicketEvents — журнал пишется той же операцией, что и переход:
|
||
// полный цикл заявки оставляет хронологию с авторами и причинами,
|
||
// а пустое окно пагинации не сбрасывает total.
|
||
func TestTicketEvents(t *testing.T) {
|
||
q, ctx := newTestQuerier(t)
|
||
tickets := NewTicketRepo(q)
|
||
projects := NewProjectRepo(q)
|
||
|
||
client := createProfile(t, ctx, q, "client")
|
||
worker := createProfile(t, ctx, q, "worker")
|
||
admin := createProfile(t, ctx, q, "admin")
|
||
|
||
projectID, objectID, address := createProjectObject(t, ctx, q, client.ID)
|
||
joinProject(t, ctx, projects, projectID, worker.ID, client.ID)
|
||
|
||
ticket := &models.Ticket{
|
||
ClientID: client.ID,
|
||
ProjectID: projectID,
|
||
ObjectID: objectID,
|
||
Address: address,
|
||
Category: "other",
|
||
Description: "заявка для журнала",
|
||
Priority: "normal",
|
||
}
|
||
if err := tickets.Create(ctx, ticket); err != nil {
|
||
t.Fatalf("create: %v", err)
|
||
}
|
||
if err := tickets.Assign(ctx, ticket.ID, worker.ID, admin.ID); err != nil {
|
||
t.Fatalf("assign: %v", err)
|
||
}
|
||
if err := tickets.Start(ctx, ticket.ID, worker.ID); err != nil {
|
||
t.Fatalf("start: %v", err)
|
||
}
|
||
if err := tickets.Done(ctx, ticket.ID, worker.ID); err != nil {
|
||
t.Fatalf("done: %v", err)
|
||
}
|
||
if err := tickets.Reject(ctx, ticket.ID, client.ID, "работа сделана криво"); err != nil {
|
||
t.Fatalf("reject: %v", err)
|
||
}
|
||
if err := tickets.Start(ctx, ticket.ID, worker.ID); err != nil {
|
||
t.Fatalf("rework start: %v", err)
|
||
}
|
||
if err := tickets.Done(ctx, ticket.ID, worker.ID); err != nil {
|
||
t.Fatalf("rework done: %v", err)
|
||
}
|
||
if err := tickets.Confirm(ctx, ticket.ID, client.ID); err != nil {
|
||
t.Fatalf("confirm: %v", err)
|
||
}
|
||
|
||
want := []struct{ action, from, to, actor, comment string }{
|
||
{"created", "", "new", client.ID, ""},
|
||
{"assigned", "new", "assigned", admin.ID, ""},
|
||
{"started", "assigned", "in_progress", worker.ID, ""},
|
||
{"done", "in_progress", "done", worker.ID, ""},
|
||
{"rejected", "done", "rejected", client.ID, "работа сделана криво"},
|
||
{"started", "rejected", "in_progress", worker.ID, ""},
|
||
{"done", "in_progress", "done", worker.ID, ""},
|
||
{"confirmed", "done", "closed", client.ID, ""},
|
||
}
|
||
events, total, err := tickets.ListEvents(ctx, ticket.ID, Page{Limit: MaxLimit})
|
||
if err != nil {
|
||
t.Fatalf("list events: %v", err)
|
||
}
|
||
if len(events) != len(want) || total != len(want) {
|
||
t.Fatalf("events = %d/%d, want %d", len(events), total, len(want))
|
||
}
|
||
for i, w := range want {
|
||
e := events[i]
|
||
if e.Action != w.action || e.FromStatus != w.from || e.ToStatus != w.to ||
|
||
e.ActorID != w.actor || e.Comment != w.comment {
|
||
t.Errorf("event %d = %s %q→%s by %s (%q), want %s %q→%s by %s (%q)",
|
||
i, e.Action, e.FromStatus, e.ToStatus, e.ActorID, e.Comment,
|
||
w.action, w.from, w.to, w.actor, w.comment)
|
||
}
|
||
if e.TicketID != ticket.ID || e.ActorName == "" || e.CreatedAt.IsZero() {
|
||
t.Errorf("event %d: ticket=%d actor_name=%q created=%v, want ticket/actor/время",
|
||
i, e.TicketID, e.ActorName, e.CreatedAt)
|
||
}
|
||
}
|
||
|
||
// Отмена: from — настоящий статус на момент отмены, а не любой из
|
||
// разрешённого списка; причина уходит в журнал.
|
||
tk2 := &models.Ticket{
|
||
ClientID: client.ID,
|
||
ProjectID: projectID,
|
||
ObjectID: objectID,
|
||
Address: address,
|
||
Category: "other",
|
||
Description: "заявка на отмену",
|
||
Priority: "normal",
|
||
}
|
||
if err := tickets.Create(ctx, tk2); err != nil {
|
||
t.Fatalf("create #2: %v", err)
|
||
}
|
||
if err := tickets.Assign(ctx, tk2.ID, worker.ID, admin.ID); err != nil {
|
||
t.Fatalf("assign #2: %v", err)
|
||
}
|
||
if err := tickets.Start(ctx, tk2.ID, worker.ID); err != nil {
|
||
t.Fatalf("start #2: %v", err)
|
||
}
|
||
from := []string{"new", "assigned", "in_progress"}
|
||
if err := tickets.Close(ctx, tk2.ID, admin.ID, "дубль заявки", from); err != nil {
|
||
t.Fatalf("close: %v", err)
|
||
}
|
||
events2, _, err := tickets.ListEvents(ctx, tk2.ID, Page{Limit: MaxLimit})
|
||
if err != nil {
|
||
t.Fatalf("list events #2: %v", err)
|
||
}
|
||
last := events2[len(events2)-1]
|
||
if last.Action != "canceled" || last.FromStatus != "in_progress" || last.ToStatus != "closed" ||
|
||
last.ActorID != admin.ID || last.Comment != "дубль заявки" {
|
||
t.Errorf("last event = %+v, want canceled in_progress→closed by admin with reason", last)
|
||
}
|
||
|
||
// Пагинация: страница из двух не двигает total; за концом — пусто,
|
||
// но total остаётся полным (отдельный COUNT с тем же WHERE).
|
||
page, total3, err := tickets.ListEvents(ctx, ticket.ID, Page{Limit: 2})
|
||
if err != nil {
|
||
t.Fatalf("page: %v", err)
|
||
}
|
||
if len(page) != 2 || total3 != len(want) {
|
||
t.Errorf("page = %d/%d, want 2/%d", len(page), total3, len(want))
|
||
}
|
||
empty, total4, err := tickets.ListEvents(ctx, ticket.ID, Page{Limit: MaxLimit, Offset: 100})
|
||
if err != nil {
|
||
t.Fatalf("page beyond the end: %v", err)
|
||
}
|
||
if len(empty) != 0 || total4 != len(want) {
|
||
t.Errorf("page beyond the end = %d/%d, want 0/%d", len(empty), total4, len(want))
|
||
}
|
||
// id desc — строго обратная хронология.
|
||
desc, _, err := tickets.ListEvents(ctx, ticket.ID, Page{Limit: MaxLimit, Sort: "id", Order: "desc"})
|
||
if err != nil {
|
||
t.Fatalf("sorted page: %v", err)
|
||
}
|
||
if len(desc) != len(events) || desc[0].ID != events[len(events)-1].ID {
|
||
t.Errorf("desc order = first id %d, want %d", desc[0].ID, events[len(events)-1].ID)
|
||
}
|
||
}
|
||
|
||
// TestTicketSchemaConstraints — ограничения схемы ловят то, что обошло бы
|
||
// handlers: статус вне списка и «назначенного без мастера».
|
||
func TestTicketSchemaConstraints(t *testing.T) {
|
||
q, ctx := newTestQuerier(t)
|
||
client := createProfile(t, ctx, q, "client")
|
||
|
||
projectID, objectID, _ := createProjectObject(t, ctx, q, client.ID)
|
||
|
||
// Мастер обязателен во всех статусах, кроме 'new' и закрытых.
|
||
expectInvalid(t, ctx, q, "assigned without worker", `
|
||
INSERT INTO tickets (client_id, project_id, object_id, address, category, description, status)
|
||
VALUES ($1, $2, $3, 'addr', 'other', 'desc', 'assigned')`, client.ID, projectID, objectID)
|
||
expectInvalid(t, ctx, q, "in_progress without worker", `
|
||
INSERT INTO tickets (client_id, project_id, object_id, address, category, description, status, started_at)
|
||
VALUES ($1, $2, $3, 'addr', 'other', 'desc', 'in_progress', now())`, client.ID, projectID, objectID)
|
||
|
||
// Статус вне белого списка.
|
||
expectInvalid(t, ctx, q, "unknown status", `
|
||
INSERT INTO tickets (client_id, project_id, object_id, address, category, description, status)
|
||
VALUES ($1, $2, $3, 'addr', 'other', 'desc', 'weird')`, client.ID, projectID, objectID)
|
||
|
||
// Назначение фиксируется временем.
|
||
expectInvalid(t, ctx, q, "assigned without assigned_at", `
|
||
INSERT INTO tickets (client_id, project_id, object_id, address, category, description, status, worker_id)
|
||
VALUES ($1, $2, $3, 'addr', 'other', 'desc', 'assigned', $1)`, client.ID, projectID, objectID)
|
||
|
||
// Категория вне белого списка.
|
||
expectInvalid(t, ctx, q, "unknown category", `
|
||
INSERT INTO tickets (client_id, project_id, object_id, address, category, description)
|
||
VALUES ($1, $2, $3, 'addr', 'teleportation', 'desc')`, client.ID, projectID, objectID)
|
||
|
||
// 0002: отклонение без причины и без done_at невозможно.
|
||
expectInvalid(t, ctx, q, "rejected without reason", `
|
||
INSERT INTO tickets (client_id, project_id, object_id, address, category, description, status, rejected_at, done_at)
|
||
VALUES ($1, $2, $3, 'addr', 'other', 'desc', 'rejected', now(), now())`, client.ID, projectID, objectID)
|
||
|
||
// 0002: закрытая заявка либо принята, либо отменена с причиной.
|
||
expectInvalid(t, ctx, q, "closed without reason", `
|
||
INSERT INTO tickets (client_id, project_id, object_id, address, category, description, status, closed_at)
|
||
VALUES ($1, $2, $3, 'addr', 'other', 'desc', 'closed', now())`, client.ID, projectID, objectID)
|
||
|
||
// 0004: заявка без объекта/проекта не существует в принципе.
|
||
expectInvalid(t, ctx, q, "ticket without object", `
|
||
INSERT INTO tickets (client_id, project_id, address, category, description)
|
||
VALUES ($1, $2, 'addr', 'other', 'desc')`, client.ID, projectID)
|
||
|
||
// 0004: составной FK ловит объект «чужого» проекта — заявка не сшивается
|
||
// из кусков разных проектов, даже если оба куска существуют.
|
||
_, otherObject, _ := createProjectObject(t, ctx, q, client.ID)
|
||
expectInvalid(t, ctx, q, "object from another project", `
|
||
INSERT INTO tickets (client_id, project_id, object_id, address, category, description)
|
||
VALUES ($1, $2, $3, 'addr', 'other', 'desc')`, client.ID, projectID, otherObject)
|
||
}
|
||
|
||
// TestTicketResolution — закрытие цикла: confirm (принято), reject (доработка),
|
||
// cancel (окончательный отказ), включая второй круг и права сторон.
|
||
func TestTicketResolution(t *testing.T) {
|
||
q, ctx := newTestQuerier(t)
|
||
tickets := NewTicketRepo(q)
|
||
|
||
client := createProfile(t, ctx, q, "client")
|
||
otherClient := createProfile(t, ctx, q, "client")
|
||
worker := createProfile(t, ctx, q, "worker")
|
||
admin := createProfile(t, ctx, q, "admin")
|
||
|
||
// Все заявки теста живут в одном объекте проекта клиента.
|
||
projectID, objectID, address := createProjectObject(t, ctx, q, client.ID)
|
||
joinProject(t, ctx, NewProjectRepo(q), projectID, worker.ID, admin.ID)
|
||
|
||
clientCancels := []string{"new", "assigned", "done", "rejected"}
|
||
adminCloses := []string{"new", "assigned", "in_progress", "done", "rejected"}
|
||
|
||
// toDone проходит путь new → assigned → in_progress → done.
|
||
toDone := func() *models.Ticket {
|
||
t.Helper()
|
||
tk := &models.Ticket{
|
||
ClientID: client.ID,
|
||
ProjectID: projectID,
|
||
ObjectID: objectID,
|
||
Address: address,
|
||
Category: "other",
|
||
Description: "test ticket",
|
||
Priority: "normal",
|
||
}
|
||
if err := tickets.Create(ctx, tk); err != nil {
|
||
t.Fatalf("create: %v", err)
|
||
}
|
||
if err := tickets.Assign(ctx, tk.ID, worker.ID, admin.ID); err != nil {
|
||
t.Fatalf("assign: %v", err)
|
||
}
|
||
if err := tickets.Start(ctx, tk.ID, worker.ID); err != nil {
|
||
t.Fatalf("start: %v", err)
|
||
}
|
||
if err := tickets.Done(ctx, tk.ID, worker.ID); err != nil {
|
||
t.Fatalf("done: %v", err)
|
||
}
|
||
return mustGetTicket(t, ctx, tickets, tk.ID)
|
||
}
|
||
|
||
t.Run("confirm закрывает заявку", func(t *testing.T) {
|
||
tk := toDone()
|
||
if err := tickets.Confirm(ctx, tk.ID, client.ID); err != nil {
|
||
t.Fatalf("confirm: %v", err)
|
||
}
|
||
got := mustGetTicket(t, ctx, tickets, tk.ID)
|
||
if got.Status != "closed" {
|
||
t.Errorf("status = %q, want closed", got.Status)
|
||
}
|
||
if got.ConfirmedAt == nil || got.ClosedAt == nil {
|
||
t.Errorf("confirmed_at=%v closed_at=%v, want both set", got.ConfirmedAt, got.ClosedAt)
|
||
}
|
||
if got.CancelReason != "" {
|
||
t.Errorf("cancel_reason = %q, want empty", got.CancelReason)
|
||
}
|
||
if got.ReworkCount != 0 {
|
||
t.Errorf("rework_count = %d, want 0", got.ReworkCount)
|
||
}
|
||
|
||
// Закрытую заявку нельзя трогать повторно.
|
||
if err := tickets.Confirm(ctx, tk.ID, client.ID); !errors.Is(err, ErrConflict) {
|
||
t.Errorf("second confirm: %v, want ErrConflict", err)
|
||
}
|
||
if err := tickets.Reject(ctx, tk.ID, client.ID, "передумал"); !errors.Is(err, ErrConflict) {
|
||
t.Errorf("reject closed: %v, want ErrConflict", err)
|
||
}
|
||
// Подтверждать чужую заявку нельзя (на репозитории — конфликт,
|
||
// handler отдаёт 403 раньше, на проверке владельца).
|
||
if err := tickets.Confirm(ctx, tk.ID, otherClient.ID); !errors.Is(err, ErrConflict) {
|
||
t.Errorf("confirm by another client: %v, want ErrConflict", err)
|
||
}
|
||
})
|
||
|
||
t.Run("reject уходит на доработку и возвращается в работу", func(t *testing.T) {
|
||
tk := toDone()
|
||
if err := tickets.Reject(ctx, tk.ID, client.ID, "течёт по-прежнему"); err != nil {
|
||
t.Fatalf("reject: %v", err)
|
||
}
|
||
rejected := mustGetTicket(t, ctx, tickets, tk.ID)
|
||
if rejected.Status != "rejected" {
|
||
t.Errorf("status = %q, want rejected", rejected.Status)
|
||
}
|
||
if rejected.RejectedAt == nil || rejected.RejectReason != "течёт по-прежнему" {
|
||
t.Errorf("rejected_at=%v reason=%q", rejected.RejectedAt, rejected.RejectReason)
|
||
}
|
||
if rejected.ReworkCount != 1 {
|
||
t.Errorf("rework_count = %d, want 1", rejected.ReworkCount)
|
||
}
|
||
if rejected.DoneAt == nil {
|
||
t.Error("done_at lost after reject")
|
||
}
|
||
|
||
// Повторный reject без новой работы — конфликт.
|
||
if err := tickets.Reject(ctx, tk.ID, client.ID, "и опять"); !errors.Is(err, ErrConflict) {
|
||
t.Errorf("second reject: %v, want ErrConflict", err)
|
||
}
|
||
// Чужой мастер не возьмёт заявку на доработку.
|
||
if err := tickets.Start(ctx, tk.ID, admin.ID); !errors.Is(err, ErrConflict) {
|
||
t.Errorf("start by another worker: %v, want ErrConflict", err)
|
||
}
|
||
|
||
// Второй круг: started_at первого круга не затирается.
|
||
firstStart := *rejected.StartedAt
|
||
if err := tickets.Start(ctx, tk.ID, worker.ID); err != nil {
|
||
t.Fatalf("start after reject: %v", err)
|
||
}
|
||
inProgress := mustGetTicket(t, ctx, tickets, tk.ID)
|
||
if inProgress.Status != "in_progress" {
|
||
t.Errorf("status = %q, want in_progress", inProgress.Status)
|
||
}
|
||
if !inProgress.StartedAt.Equal(firstStart) {
|
||
t.Errorf("started_at changed: %v -> %v, want preserved", firstStart, inProgress.StartedAt)
|
||
}
|
||
|
||
if err := tickets.Done(ctx, tk.ID, worker.ID); err != nil {
|
||
t.Fatalf("second done: %v", err)
|
||
}
|
||
if err := tickets.Confirm(ctx, tk.ID, client.ID); err != nil {
|
||
t.Fatalf("confirm after rework: %v", err)
|
||
}
|
||
|
||
final := mustGetTicket(t, ctx, tickets, tk.ID)
|
||
if final.Status != "closed" {
|
||
t.Errorf("status = %q, want closed", final.Status)
|
||
}
|
||
if final.ReworkCount != 1 {
|
||
t.Errorf("rework_count = %d, want 1", final.ReworkCount)
|
||
}
|
||
if final.RejectReason == "" {
|
||
t.Error("reject_reason lost after rework")
|
||
}
|
||
if final.ConfirmedAt == nil {
|
||
t.Error("confirmed_at not set after final confirm")
|
||
}
|
||
})
|
||
|
||
t.Run("cancel из new закрывает заявку без мастера", func(t *testing.T) {
|
||
tk := &models.Ticket{
|
||
ClientID: client.ID,
|
||
ProjectID: projectID,
|
||
ObjectID: objectID,
|
||
Address: address,
|
||
Category: "other",
|
||
Description: "test ticket",
|
||
Priority: "normal",
|
||
}
|
||
if err := tickets.Create(ctx, tk); err != nil {
|
||
t.Fatalf("create: %v", err)
|
||
}
|
||
|
||
if err := tickets.Close(ctx, tk.ID, client.ID, "передумал", clientCancels); err != nil {
|
||
t.Fatalf("cancel: %v", err)
|
||
}
|
||
got := mustGetTicket(t, ctx, tickets, tk.ID)
|
||
if got.Status != "closed" {
|
||
t.Errorf("status = %q, want closed", got.Status)
|
||
}
|
||
// Регресс на снятый констрейнт: закрытая заявка может быть без мастера.
|
||
if got.WorkerID != nil {
|
||
t.Errorf("worker_id = %v, want nil", *got.WorkerID)
|
||
}
|
||
if got.CancelReason != "передумал" || got.ClosedAt == nil || got.ConfirmedAt != nil {
|
||
t.Errorf("cancel_reason=%q closed_at=%v confirmed_at=%v",
|
||
got.CancelReason, got.ClosedAt, got.ConfirmedAt)
|
||
}
|
||
if got.ClosedBy == nil || *got.ClosedBy != client.ID {
|
||
t.Errorf("closed_by = %v, want %s", got.ClosedBy, client.ID)
|
||
}
|
||
|
||
// Закрытую нельзя назначить.
|
||
if err := tickets.Assign(ctx, tk.ID, worker.ID, admin.ID); !errors.Is(err, ErrConflict) {
|
||
t.Errorf("assign closed: %v, want ErrConflict", err)
|
||
}
|
||
})
|
||
|
||
t.Run("клиент не отменяет работающую заявку, админ может", func(t *testing.T) {
|
||
tk := &models.Ticket{
|
||
ClientID: client.ID,
|
||
ProjectID: projectID,
|
||
ObjectID: objectID,
|
||
Address: address,
|
||
Category: "other",
|
||
Description: "test ticket",
|
||
Priority: "normal",
|
||
}
|
||
if err := tickets.Create(ctx, tk); err != nil {
|
||
t.Fatalf("create: %v", err)
|
||
}
|
||
if err := tickets.Assign(ctx, tk.ID, worker.ID, admin.ID); err != nil {
|
||
t.Fatalf("assign: %v", err)
|
||
}
|
||
if err := tickets.Start(ctx, tk.ID, worker.ID); err != nil {
|
||
t.Fatalf("start: %v", err)
|
||
}
|
||
|
||
if err := tickets.Close(ctx, tk.ID, client.ID, "не хочу", clientCancels); !errors.Is(err, ErrConflict) {
|
||
t.Errorf("client cancel in_progress: %v, want ErrConflict", err)
|
||
}
|
||
if err := tickets.Close(ctx, tk.ID, admin.ID, "работа приостановлена", adminCloses); err != nil {
|
||
t.Fatalf("admin close: %v", err)
|
||
}
|
||
got := mustGetTicket(t, ctx, tickets, tk.ID)
|
||
if got.Status != "closed" || got.ClosedBy == nil || *got.ClosedBy != admin.ID {
|
||
t.Errorf("status=%q closed_by=%v, want closed by admin", got.Status, got.ClosedBy)
|
||
}
|
||
})
|
||
|
||
t.Run("несуществующая заявка", func(t *testing.T) {
|
||
if err := tickets.Confirm(ctx, 999999999, client.ID); !errors.Is(err, ErrNotFound) {
|
||
t.Errorf("confirm missing: %v, want ErrNotFound", err)
|
||
}
|
||
if err := tickets.Close(ctx, 999999999, client.ID, "reason", clientCancels); !errors.Is(err, ErrNotFound) {
|
||
t.Errorf("cancel missing: %v, want ErrNotFound", err)
|
||
}
|
||
})
|
||
}
|
||
|
||
// expectInvalid ждёт нарушение ограничения целостности.
|
||
func expectInvalid(t *testing.T, ctx context.Context, q Querier, label, query string, args ...any) {
|
||
t.Helper()
|
||
expectError(t, ctx, q, label, func() error {
|
||
_, err := q.Exec(ctx, query, args...)
|
||
return err
|
||
}, ErrInvalid)
|
||
}
|
||
|
||
// expectError выполняет проверку под savepoint и откатывает к нему.
|
||
// Нужно для *ожидаемых* нарушений: в Postgres ошибка внутри транзакции
|
||
// блокирует её до самого конца (25P02), а остальные проверки в тесте
|
||
// ещё должны пройти.
|
||
func expectError(t *testing.T, ctx context.Context, q Querier, label string, fn func() error, want error) {
|
||
t.Helper()
|
||
|
||
const sp = "expect_error"
|
||
mustExec := func(step, sql string) {
|
||
t.Helper()
|
||
if _, err := q.Exec(ctx, sql); err != nil {
|
||
t.Fatalf("%s: %s: %v", label, step, err)
|
||
}
|
||
}
|
||
|
||
mustExec("savepoint", "SAVEPOINT "+sp)
|
||
err := fn()
|
||
if !errors.Is(classify(err), want) {
|
||
t.Errorf("%s: err = %v, want %v", label, err, want)
|
||
}
|
||
mustExec("rollback to savepoint", "ROLLBACK TO SAVEPOINT "+sp)
|
||
mustExec("release savepoint", "RELEASE SAVEPOINT "+sp)
|
||
}
|
||
|
||
// TestProfileDuplicateEmail — нарушение уникальности это конфликт (409),
|
||
// а не внутренняя ошибка.
|
||
func TestProfileDuplicateEmail(t *testing.T) {
|
||
q, ctx := newTestQuerier(t)
|
||
|
||
first := createProfile(t, ctx, q, "client")
|
||
second := &models.Profile{
|
||
ID: uuid.NewString(),
|
||
Email: first.Email,
|
||
PasswordHash: "test-hash",
|
||
Name: "second",
|
||
Role: "client",
|
||
IsActive: true,
|
||
}
|
||
err := NewProfileRepo(q).Create(ctx, second)
|
||
if !errors.Is(err, ErrConflict) {
|
||
t.Errorf("duplicate email: err = %v, want ErrConflict", err)
|
||
}
|
||
}
|
||
|
||
// TestTicketListFilters — список режется по роли: клиент видит только своё,
|
||
// мастер — только назначенные ему заявки.
|
||
func TestTicketListFilters(t *testing.T) {
|
||
q, ctx := newTestQuerier(t)
|
||
tickets := NewTicketRepo(q)
|
||
|
||
clientA := createProfile(t, ctx, q, "client")
|
||
clientB := createProfile(t, ctx, q, "client")
|
||
worker := createProfile(t, ctx, q, "worker")
|
||
admin := createProfile(t, ctx, q, "admin")
|
||
|
||
// У каждого клиента свой проект с объектом; мастер состоит в проекте A.
|
||
projA, objA, addrA := createProjectObject(t, ctx, q, clientA.ID)
|
||
projB, objB, addrB := createProjectObject(t, ctx, q, clientB.ID)
|
||
joinProject(t, ctx, NewProjectRepo(q), projA, worker.ID, admin.ID)
|
||
|
||
mk := func(clientID string, projectID, objectID int64, address string) *models.Ticket {
|
||
tk := &models.Ticket{
|
||
ClientID: clientID,
|
||
ProjectID: projectID,
|
||
ObjectID: objectID,
|
||
Address: address,
|
||
Category: "other",
|
||
Description: "test ticket",
|
||
Priority: "normal",
|
||
}
|
||
if err := tickets.Create(ctx, tk); err != nil {
|
||
t.Fatalf("create ticket: %v", err)
|
||
}
|
||
return tk
|
||
}
|
||
|
||
ticketA := mk(clientA.ID, projA, objA, addrA)
|
||
ticketB := mk(clientB.ID, projB, objB, addrB)
|
||
if err := tickets.Assign(ctx, ticketA.ID, worker.ID, admin.ID); err != nil {
|
||
t.Fatalf("assign: %v", err)
|
||
}
|
||
|
||
byClient, _, err := tickets.List(ctx, ListFilter{ClientID: clientA.ID, Page: Page{Limit: MaxLimit}})
|
||
if err != nil {
|
||
t.Fatalf("list by client: %v", err)
|
||
}
|
||
if len(byClient) != 1 || byClient[0].ID != ticketA.ID {
|
||
t.Errorf("client A sees %v, want only ticket %d", ids(byClient), ticketA.ID)
|
||
}
|
||
|
||
byWorker, _, err := tickets.List(ctx, ListFilter{WorkerID: worker.ID, Page: Page{Limit: MaxLimit}})
|
||
if err != nil {
|
||
t.Fatalf("list by worker: %v", err)
|
||
}
|
||
if len(byWorker) != 1 || byWorker[0].ID != ticketA.ID {
|
||
t.Errorf("worker sees %v, want only ticket %d", ids(byWorker), ticketA.ID)
|
||
}
|
||
|
||
byStatus, _, err := tickets.List(ctx, ListFilter{Status: "new", Page: Page{Limit: MaxLimit}})
|
||
if err != nil {
|
||
t.Fatalf("list by status: %v", err)
|
||
}
|
||
for _, tk := range byStatus {
|
||
if tk.ID == ticketA.ID {
|
||
t.Errorf("ticket %d is assigned but appeared in status=new", ticketA.ID)
|
||
}
|
||
if tk.ID == ticketB.ID && tk.Status != "new" {
|
||
t.Errorf("ticket %d status = %q, want new", tk.ID, tk.Status)
|
||
}
|
||
}
|
||
|
||
// Структурные фильтры: проект и объект режут независимо от людей.
|
||
byProject, _, err := tickets.List(ctx, ListFilter{ProjectID: projA, Page: Page{Limit: MaxLimit}})
|
||
if err != nil {
|
||
t.Fatalf("list by project: %v", err)
|
||
}
|
||
if len(byProject) != 1 || byProject[0].ID != ticketA.ID {
|
||
t.Errorf("project A sees %v, want only ticket %d", ids(byProject), ticketA.ID)
|
||
}
|
||
|
||
byObject, _, err := tickets.List(ctx, ListFilter{ObjectID: objB, Page: Page{Limit: MaxLimit}})
|
||
if err != nil {
|
||
t.Fatalf("list by object: %v", err)
|
||
}
|
||
if len(byObject) != 1 || byObject[0].ID != ticketB.ID {
|
||
t.Errorf("object B sees %v, want only ticket %d", ids(byObject), ticketB.ID)
|
||
}
|
||
}
|
||
|
||
// TestFreeForWorkerVisibility — что видит мастер: назначенные ему
|
||
// плюс свободные (new) заявки проектов, где он активный участник.
|
||
// Чужая new-заявка не видна ни в каком виде — изоляция организаций.
|
||
func TestFreeForWorkerVisibility(t *testing.T) {
|
||
q, ctx := newTestQuerier(t)
|
||
tickets := NewTicketRepo(q)
|
||
|
||
clientA := createProfile(t, ctx, q, "client")
|
||
clientB := createProfile(t, ctx, q, "client")
|
||
worker := createProfile(t, ctx, q, "worker")
|
||
admin := createProfile(t, ctx, q, "admin")
|
||
|
||
projA, objA, addrA := createProjectObject(t, ctx, q, clientA.ID)
|
||
projB, objB, addrB := createProjectObject(t, ctx, q, clientB.ID)
|
||
joinProject(t, ctx, NewProjectRepo(q), projA, worker.ID, admin.ID)
|
||
|
||
mk := func(clientID string, projectID, objectID int64, address string) *models.Ticket {
|
||
tk := &models.Ticket{
|
||
ClientID: clientID,
|
||
ProjectID: projectID,
|
||
ObjectID: objectID,
|
||
Address: address,
|
||
Category: "other",
|
||
Description: "test ticket",
|
||
Priority: "normal",
|
||
}
|
||
if err := tickets.Create(ctx, tk); err != nil {
|
||
t.Fatalf("create ticket: %v", err)
|
||
}
|
||
return tk
|
||
}
|
||
|
||
assigned := mk(clientA.ID, projA, objA, addrA) // назначена мастеру
|
||
freeA := mk(clientA.ID, projA, objA, addrA) // свободная в его проекте
|
||
freeB := mk(clientB.ID, projB, objB, addrB) // свободная чужая
|
||
if err := tickets.Assign(ctx, assigned.ID, worker.ID, clientA.ID); err != nil {
|
||
t.Fatalf("assign: %v", err)
|
||
}
|
||
|
||
list, total, err := tickets.List(ctx, ListFilter{
|
||
FreeForWorker: worker.ID, Page: Page{Limit: MaxLimit},
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("list free for worker: %v", err)
|
||
}
|
||
got := ids(list)
|
||
want := map[int64]bool{assigned.ID: true, freeA.ID: true}
|
||
if total != 2 || len(got) != 2 {
|
||
t.Errorf("worker sees %v (total %d), want [%d %d]", got, total, assigned.ID, freeA.ID)
|
||
}
|
||
for _, id := range got {
|
||
if !want[id] {
|
||
t.Errorf("worker must not see ticket %d (чужой проект или статус)", id)
|
||
}
|
||
}
|
||
|
||
// С фильтром по статусу чужая заявка тоже не подглядеть.
|
||
byNew, _, err := tickets.List(ctx, ListFilter{
|
||
FreeForWorker: worker.ID, Status: "new", Page: Page{Limit: MaxLimit},
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("list free by status: %v", err)
|
||
}
|
||
for _, tk := range byNew {
|
||
if tk.ID == freeB.ID {
|
||
t.Errorf("ticket %d of foreign project appeared in status=new", tk.ID)
|
||
}
|
||
}
|
||
if len(byNew) != 1 || byNew[0].ID != freeA.ID {
|
||
t.Errorf("worker sees new %v, want only [%d]", ids(byNew), freeA.ID)
|
||
}
|
||
}
|
||
|
||
// TestDeleteExpiredTickets — автоочистка старых заявок: удаляются только
|
||
// закрытые с closed_at раньше отсечки; свеже-закрытые и незакрытые
|
||
// остаются. События журнала и строки фото уходят каскадом, ключи
|
||
// объектов возвращаются для чистки в хранилище.
|
||
//
|
||
// Срезы ids/keys проверяются через вхождение, а не на точное равенство:
|
||
// тест ходит в общую БД, и закрытые старше отсечки заявки из других
|
||
// данных удалятся тоже (транзакция откатится — данные не пропадут).
|
||
func TestDeleteExpiredTickets(t *testing.T) {
|
||
q, ctx := newTestQuerier(t)
|
||
tickets := NewTicketRepo(q)
|
||
photos := NewPhotoRepo(q)
|
||
|
||
client := createProfile(t, ctx, q, "client")
|
||
worker := createProfile(t, ctx, q, "worker")
|
||
admin := createProfile(t, ctx, q, "admin")
|
||
|
||
projectID, objectID, address := createProjectObject(t, ctx, q, client.ID)
|
||
joinProject(t, ctx, NewProjectRepo(q), projectID, worker.ID, admin.ID)
|
||
|
||
mk := func(desc string) *models.Ticket {
|
||
tk := &models.Ticket{
|
||
ClientID: client.ID,
|
||
ProjectID: projectID,
|
||
ObjectID: objectID,
|
||
Address: address,
|
||
Category: "plumbing",
|
||
Description: desc,
|
||
Priority: "normal",
|
||
}
|
||
if err := tickets.Create(ctx, tk); err != nil {
|
||
t.Fatalf("create ticket: %v", err)
|
||
}
|
||
return tk
|
||
}
|
||
|
||
// closeTicket — полный путь до закрытия: назначение, старт, фото
|
||
// (загрузка возможна только в in_progress) и cancel; days > 0
|
||
// отодвигает closed_at в прошлое.
|
||
closeTicket := func(desc string, days int) (*models.Ticket, string) {
|
||
tk := mk(desc)
|
||
if err := tickets.Assign(ctx, tk.ID, worker.ID, admin.ID); err != nil {
|
||
t.Fatalf("assign %q: %v", desc, err)
|
||
}
|
||
if err := tickets.Start(ctx, tk.ID, worker.ID); err != nil {
|
||
t.Fatalf("start %q: %v", desc, err)
|
||
}
|
||
ph := photoMeta(tk.ID, worker.ID, 1, 10)
|
||
if _, err := photos.AddPhoto(ctx, ph, "worker"); err != nil {
|
||
t.Fatalf("add photo %q: %v", desc, err)
|
||
}
|
||
if err := tickets.Close(ctx, tk.ID, client.ID, "отмена", []string{"in_progress"}); err != nil {
|
||
t.Fatalf("close %q: %v", desc, err)
|
||
}
|
||
if days > 0 {
|
||
if _, err := q.Exec(ctx,
|
||
`UPDATE tickets SET closed_at = now() - make_interval(days => $2::int)
|
||
WHERE id = $1`, tk.ID, days); err != nil {
|
||
t.Fatalf("backdate closed_at: %v", err)
|
||
}
|
||
}
|
||
return tk, ph.ObjectKey
|
||
}
|
||
|
||
oldClosed, oldKey := closeTicket("закрыта 61 день назад", 61)
|
||
freshClosed, freshKey := closeTicket("закрыта вчера", 1)
|
||
oldOpen := mk("незакрытая: статус new, в отсечку не попадает")
|
||
|
||
const retention = 60 * 24 * time.Hour
|
||
ids, keys, err := tickets.DeleteExpired(ctx, time.Now().Add(-retention))
|
||
if err != nil {
|
||
t.Fatalf("delete expired: %v", err)
|
||
}
|
||
if !slices.Contains(ids, oldClosed.ID) {
|
||
t.Errorf("deleted ids = %v, want to include %d", ids, oldClosed.ID)
|
||
}
|
||
if slices.Contains(ids, freshClosed.ID) {
|
||
t.Errorf("fresh closed ticket %d deleted", freshClosed.ID)
|
||
}
|
||
if slices.Contains(ids, oldOpen.ID) {
|
||
t.Errorf("open ticket %d deleted", oldOpen.ID)
|
||
}
|
||
if !slices.Contains(keys, oldKey) {
|
||
t.Errorf("photo keys = %v, want to include %s", keys, oldKey)
|
||
}
|
||
if slices.Contains(keys, freshKey) {
|
||
t.Errorf("fresh ticket photo key %s cleaned up", freshKey)
|
||
}
|
||
|
||
if _, err := tickets.GetByID(ctx, oldClosed.ID); !errors.Is(err, ErrNotFound) {
|
||
t.Errorf("old closed ticket survived: err = %v", err)
|
||
}
|
||
if _, err := tickets.GetByID(ctx, freshClosed.ID); err != nil {
|
||
t.Errorf("fresh closed ticket removed: %v", err)
|
||
}
|
||
if _, err := tickets.GetByID(ctx, oldOpen.ID); err != nil {
|
||
t.Errorf("open ticket removed: %v", err)
|
||
}
|
||
|
||
// Каскад: у удалённой заявки нет ни журнала, ни строк фото;
|
||
// у переживших всё на месте.
|
||
countRows := func(table string, ticketID int64) int {
|
||
t.Helper()
|
||
var n int
|
||
if err := q.QueryRow(ctx,
|
||
`SELECT COUNT(*) FROM `+table+` WHERE ticket_id = $1`, ticketID).Scan(&n); err != nil {
|
||
t.Fatalf("count %s: %v", table, err)
|
||
}
|
||
return n
|
||
}
|
||
if n := countRows("ticket_events", oldClosed.ID); n != 0 {
|
||
t.Errorf("events of deleted ticket: %d, want 0 (каскад)", n)
|
||
}
|
||
if n := countRows("ticket_photos", oldClosed.ID); n != 0 {
|
||
t.Errorf("photo rows of deleted ticket: %d, want 0 (каскад)", n)
|
||
}
|
||
if n := countRows("ticket_events", freshClosed.ID); n == 0 {
|
||
t.Error("events of surviving ticket removed")
|
||
}
|
||
if n := countRows("ticket_photos", freshClosed.ID); n != 1 {
|
||
t.Errorf("photo rows of surviving ticket: %d, want 1", n)
|
||
}
|
||
|
||
// Повторный прогон старую заявку уже не находит — чистка идемпотентна.
|
||
ids2, keys2, err := tickets.DeleteExpired(ctx, time.Now().Add(-retention))
|
||
if err != nil {
|
||
t.Fatalf("second cleanup: %v", err)
|
||
}
|
||
if slices.Contains(ids2, oldClosed.ID) {
|
||
t.Errorf("second cleanup deleted %d again", oldClosed.ID)
|
||
}
|
||
if slices.Contains(keys2, oldKey) {
|
||
t.Errorf("second cleanup returned key %s again", oldKey)
|
||
}
|
||
}
|
||
|
||
func ids(tickets []models.Ticket) []int64 {
|
||
out := make([]int64, 0, len(tickets))
|
||
for _, tk := range tickets {
|
||
out = append(out, tk.ID)
|
||
}
|
||
return out
|
||
}
|