axe/internal/repository/ticket_test.go

1014 lines
39 KiB
Go
Raw Permalink Normal View History

2026-10-18 19:07:25 +00:00
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
}