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

1014 lines
39 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

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

package repository
import (
"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
}