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 }