585 lines
24 KiB
Go
585 lines
24 KiB
Go
package repository
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"testing"
|
||
"time"
|
||
|
||
"building-mgmt/internal/models"
|
||
)
|
||
|
||
// Тесты модели «проект → объекты → мастера → заявки» (миграция 0004).
|
||
// Проверяем то, что держится на SQL, а не на хендлерах: чужие сущности
|
||
// неотличимы от несуществующих, членство мастера и принадлежность объекта
|
||
// ловятся репозиторием/БД.
|
||
|
||
// TestProjectOwnership — проект виден только своему клиенту, админской
|
||
// «пустой» выборке — любой; чужой неотличим от несуществующего.
|
||
func TestProjectOwnership(t *testing.T) {
|
||
q, ctx := newTestQuerier(t)
|
||
repo := NewProjectRepo(q)
|
||
|
||
owner := createProfile(t, ctx, q, "client")
|
||
stranger := createProfile(t, ctx, q, "client")
|
||
|
||
p := &models.Project{ClientID: owner.ID, Name: "ЖК Север", IsActive: true}
|
||
if err := repo.CreateProject(ctx, p); err != nil {
|
||
t.Fatalf("create project: %v", err)
|
||
}
|
||
|
||
// Дубликат имени у того же клиента — конфликт (под savepoint:
|
||
// ожидаемая ошибка не должна отравить остальную транзакцию теста).
|
||
dup := &models.Project{ClientID: owner.ID, Name: "ЖК Север", IsActive: true}
|
||
expectError(t, ctx, q, "duplicate project name",
|
||
func() error { return repo.CreateProject(ctx, dup) }, ErrConflict)
|
||
// То же имя у другого клиента — норма (свои пространства имён).
|
||
other := &models.Project{ClientID: stranger.ID, Name: "ЖК Север", IsActive: true}
|
||
if err := repo.CreateProject(ctx, other); err != nil {
|
||
t.Errorf("same name for another client: err = %v, want nil", err)
|
||
}
|
||
|
||
// Своё лицо — находит, чужое — нет, админская выборка — находит.
|
||
if _, err := repo.OwnedProject(ctx, p.ID, owner.ID); err != nil {
|
||
t.Errorf("owner access: err = %v, want nil", err)
|
||
}
|
||
if _, err := repo.OwnedProject(ctx, p.ID, stranger.ID); !errors.Is(err, ErrNotFound) {
|
||
t.Errorf("stranger access: err = %v, want ErrNotFound", err)
|
||
}
|
||
if _, err := repo.OwnedProject(ctx, p.ID, ""); err != nil {
|
||
t.Errorf("admin access: err = %v, want nil", err)
|
||
}
|
||
|
||
// Списки: клиент видит только своё.
|
||
own, total, err := repo.ListProjects(ctx, owner.ID, Page{Limit: MaxLimit})
|
||
if err != nil {
|
||
t.Fatalf("list own: %v", err)
|
||
}
|
||
if len(own) != 1 || own[0].ID != p.ID {
|
||
t.Errorf("owner list = %v, want only project %d", projectIDs(own), p.ID)
|
||
}
|
||
if total != 1 {
|
||
t.Errorf("owner total = %d, want 1", total)
|
||
}
|
||
otherList, _, err := repo.ListProjects(ctx, stranger.ID, Page{Limit: MaxLimit})
|
||
if err != nil {
|
||
t.Fatalf("list stranger: %v", err)
|
||
}
|
||
if len(otherList) != 1 || otherList[0].ID != other.ID {
|
||
t.Errorf("stranger list = %v, want only own project", projectIDs(otherList))
|
||
}
|
||
}
|
||
|
||
// TestObjectBelongsToProject — объект живёт в проекте: чужой клиент его не
|
||
// читает, а составной FK не даёт сшить заявку из объекта и проекта
|
||
// разных контуров.
|
||
func TestObjectBelongsToProject(t *testing.T) {
|
||
q, ctx := newTestQuerier(t)
|
||
repo := NewProjectRepo(q)
|
||
|
||
owner := createProfile(t, ctx, q, "client")
|
||
stranger := createProfile(t, ctx, q, "client")
|
||
|
||
p := &models.Project{ClientID: owner.ID, Name: "БЦ Альфа", IsActive: true}
|
||
if err := repo.CreateProject(ctx, p); err != nil {
|
||
t.Fatalf("create project: %v", err)
|
||
}
|
||
o := &models.Object{ProjectID: p.ID, Name: "Корпус 1", Address: "ул. Мира, 5", IsActive: true}
|
||
if err := repo.CreateObject(ctx, o); err != nil {
|
||
t.Fatalf("create object: %v", err)
|
||
}
|
||
|
||
// Дубликат имени объекта в проекте — конфликт (под savepoint).
|
||
dup := &models.Object{ProjectID: p.ID, Name: "Корпус 1", Address: "ул. Мира, 6", IsActive: true}
|
||
expectError(t, ctx, q, "duplicate object name",
|
||
func() error { return repo.CreateObject(ctx, dup) }, ErrConflict)
|
||
|
||
if _, err := repo.OwnedObject(ctx, o.ID, owner.ID); err != nil {
|
||
t.Errorf("owner access: err = %v, want nil", err)
|
||
}
|
||
if _, err := repo.OwnedObject(ctx, o.ID, stranger.ID); !errors.Is(err, ErrNotFound) {
|
||
t.Errorf("stranger access: err = %v, want ErrNotFound", err)
|
||
}
|
||
|
||
// Правка адреса меняет объект; новые заявки возьмут новый адрес.
|
||
newAddr := "ул. Мира, 5а"
|
||
if err := repo.UpdateObject(ctx, o.ID, nil, &newAddr, nil); err != nil {
|
||
t.Fatalf("update object: %v", err)
|
||
}
|
||
got, err := repo.OwnedObject(ctx, o.ID, "")
|
||
if err != nil {
|
||
t.Fatalf("get object: %v", err)
|
||
}
|
||
if got.Address != newAddr {
|
||
t.Errorf("address = %q, want %q", got.Address, newAddr)
|
||
}
|
||
if !got.ProjectActive {
|
||
t.Error("project_active = false, want true")
|
||
}
|
||
|
||
// Составной FK: объект чужого проекта в заявку не проходит.
|
||
other := &models.Project{ClientID: owner.ID, Name: "БЦ Бета", IsActive: true}
|
||
if err := repo.CreateProject(ctx, other); err != nil {
|
||
t.Fatalf("create other project: %v", err)
|
||
}
|
||
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')`,
|
||
owner.ID, other.ID, o.ID)
|
||
}
|
||
|
||
// TestProjectWorkers — мастера проекта: членство только по приглашению
|
||
// (invite → accept), дубликаты с точной причиной и отзыв.
|
||
func TestProjectWorkers(t *testing.T) {
|
||
q, ctx := newTestQuerier(t)
|
||
repo := NewProjectRepo(q)
|
||
|
||
owner := createProfile(t, ctx, q, "client")
|
||
worker := createProfile(t, ctx, q, "worker")
|
||
strangerWorker := createProfile(t, ctx, q, "worker")
|
||
|
||
p := &models.Project{ClientID: owner.ID, Name: "ЖК Восток", IsActive: true}
|
||
if err := repo.CreateProject(ctx, p); err != nil {
|
||
t.Fatalf("create project: %v", err)
|
||
}
|
||
|
||
// До приглашения членства нет вообще — назначить такого мастера нельзя.
|
||
if status, err := repo.MembershipStatus(ctx, worker.ID, p.ID); err != nil || status != "" {
|
||
t.Errorf("membership before invite: status=%q err=%v, want empty/nil", status, err)
|
||
}
|
||
|
||
// Приглашение: строка появляется сразу, но рабочей она ещё не.
|
||
expires, err := repo.InviteWorker(ctx, p.ID, worker.ID, owner.ID)
|
||
if err != nil {
|
||
t.Fatalf("invite: %v", err)
|
||
}
|
||
if !expires.After(time.Now()) {
|
||
t.Errorf("expires_at = %v, want in the future", expires)
|
||
}
|
||
if status, err := repo.MembershipStatus(ctx, worker.ID, p.ID); err != nil || status != "invited" {
|
||
t.Errorf("membership after invite: status=%q err=%v, want invited/nil", status, err)
|
||
}
|
||
// Повторное приглашение по висящему — конфликт с точной причиной
|
||
// (под savepoint, чтобы не отравить транзакцию теста).
|
||
expectError(t, ctx, q, "duplicate invite",
|
||
func() error { _, err := repo.InviteWorker(ctx, p.ID, worker.ID, owner.ID); return err },
|
||
ErrInvitationPending)
|
||
|
||
// Мастер принял — членство активное.
|
||
if err := repo.AcceptInvitation(ctx, p.ID, worker.ID); err != nil {
|
||
t.Fatalf("accept: %v", err)
|
||
}
|
||
if status, err := repo.MembershipStatus(ctx, worker.ID, p.ID); err != nil || status != "active" {
|
||
t.Errorf("membership after accept: status=%q err=%v, want active/nil", status, err)
|
||
}
|
||
// Активного мастера заново не зовут и повторно не принимают.
|
||
expectError(t, ctx, q, "invite active member",
|
||
func() error { _, err := repo.InviteWorker(ctx, p.ID, worker.ID, owner.ID); return err },
|
||
ErrMembershipActive)
|
||
expectError(t, ctx, q, "accept twice",
|
||
func() error { return repo.AcceptInvitation(ctx, p.ID, worker.ID) },
|
||
ErrInvitationNotPending)
|
||
|
||
// Чужой мастер здесь не состоит.
|
||
if status, err := repo.MembershipStatus(ctx, strangerWorker.ID, p.ID); err != nil || status != "" {
|
||
t.Errorf("foreign membership: status=%q err=%v, want empty/nil", status, err)
|
||
}
|
||
|
||
// Состав проекта: профиль плюс статус, без хеша пароля.
|
||
workers, total, err := repo.ListWorkers(ctx, p.ID, Page{Limit: MaxLimit})
|
||
if err != nil {
|
||
t.Fatalf("list workers: %v", err)
|
||
}
|
||
if len(workers) != 1 || workers[0].ID != worker.ID || workers[0].Status != "active" {
|
||
t.Errorf("workers = %v (status %q), want only %s and active",
|
||
workerIDs(workers), workers[0].Status, worker.ID)
|
||
}
|
||
if total != 1 {
|
||
t.Errorf("workers total = %d, want 1", total)
|
||
}
|
||
if workers[0].PasswordHash != "" {
|
||
t.Error("password hash leaked into worker list")
|
||
}
|
||
|
||
// Отзыв без открытых заявок — чисто.
|
||
if err := repo.RemoveWorker(ctx, p.ID, worker.ID); err != nil {
|
||
t.Fatalf("remove worker: %v", err)
|
||
}
|
||
if err := repo.RemoveWorker(ctx, p.ID, worker.ID); !errors.Is(err, ErrNotFound) {
|
||
t.Errorf("second remove: err = %v, want ErrNotFound", err)
|
||
}
|
||
}
|
||
|
||
// TestInvitationDeclineAndExpiry — отказ, просрочка и повторное приглашение.
|
||
// Всё живёт на уровне SQL: обход хендлера статус не поменяет.
|
||
func TestInvitationDeclineAndExpiry(t *testing.T) {
|
||
q, ctx := newTestQuerier(t)
|
||
repo := NewProjectRepo(q)
|
||
|
||
owner := createProfile(t, ctx, q, "client")
|
||
worker := createProfile(t, ctx, q, "worker")
|
||
other := createProfile(t, ctx, q, "worker")
|
||
|
||
p := &models.Project{ClientID: owner.ID, Name: "ЖК Закат", IsActive: true}
|
||
if err := repo.CreateProject(ctx, p); err != nil {
|
||
t.Fatalf("create project: %v", err)
|
||
}
|
||
|
||
// Приглашение видно мастеру сразу: проект, кто пригласил, срок.
|
||
if _, err := repo.InviteWorker(ctx, p.ID, worker.ID, owner.ID); err != nil {
|
||
t.Fatalf("invite: %v", err)
|
||
}
|
||
invites, total, err := repo.ListInvitations(ctx, worker.ID, Page{Limit: MaxLimit})
|
||
if err != nil {
|
||
t.Fatalf("list invitations: %v", err)
|
||
}
|
||
if len(invites) != 1 || total != 1 {
|
||
t.Fatalf("invitations = %d/%d, want 1/1", len(invites), total)
|
||
}
|
||
got := invites[0]
|
||
if got.ProjectID != p.ID || got.ProjectName != p.Name ||
|
||
got.InvitedBy != owner.ID || got.InvitedName != owner.Name {
|
||
t.Errorf("invitation = %+v, want project %d and inviter %s", got, p.ID, owner.ID)
|
||
}
|
||
if !got.ExpiresAt.After(time.Now()) || !got.InvitedAt.Before(got.ExpiresAt) {
|
||
t.Errorf("invitation times: invited=%v expires=%v, want a future deadline", got.InvitedAt, got.ExpiresAt)
|
||
}
|
||
|
||
// Мастер отклонил: строка помечена, ждать решения больше нечего.
|
||
if err := repo.DeclineInvitation(ctx, p.ID, worker.ID); err != nil {
|
||
t.Fatalf("decline: %v", err)
|
||
}
|
||
if status, err := repo.MembershipStatus(ctx, worker.ID, p.ID); err != nil || status != "declined" {
|
||
t.Errorf("membership after decline: status=%q err=%v, want declined/nil", status, err)
|
||
}
|
||
expectError(t, ctx, q, "accept declined",
|
||
func() error { return repo.AcceptInvitation(ctx, p.ID, worker.ID) },
|
||
ErrInvitationNotPending)
|
||
// Отказавшему приглашения не видно.
|
||
invites, total, err = repo.ListInvitations(ctx, worker.ID, Page{Limit: MaxLimit})
|
||
if err != nil {
|
||
t.Fatalf("list after decline: %v", err)
|
||
}
|
||
if len(invites) != 0 || total != 0 {
|
||
t.Errorf("invitations after decline = %d/%d, want empty", len(invites), total)
|
||
}
|
||
|
||
// Повторное приглашение перезапускает ту же строку — не дубль.
|
||
if _, err := repo.InviteWorker(ctx, p.ID, worker.ID, owner.ID); err != nil {
|
||
t.Errorf("re-invite after decline: err = %v, want nil", err)
|
||
}
|
||
|
||
// Просрочка: принять нельзя (409 invitation expired), но строка
|
||
// остаётся «висячей», пока клиент не отзовёт её DELETE'ом.
|
||
if err := expireInvitations(ctx, q, p.ID, worker.ID); err != nil {
|
||
t.Fatalf("expire: %v", err)
|
||
}
|
||
expectError(t, ctx, q, "accept expired",
|
||
func() error { return repo.AcceptInvitation(ctx, p.ID, worker.ID) },
|
||
ErrInvitationExpired)
|
||
// Просроченное не отдаётся в списке: принимать уже нечего.
|
||
invites, total, err = repo.ListInvitations(ctx, worker.ID, Page{Limit: MaxLimit})
|
||
if err != nil {
|
||
t.Fatalf("list after expiry: %v", err)
|
||
}
|
||
if len(invites) != 0 || total != 0 {
|
||
t.Errorf("expired invitation listed: %d/%d, want empty", len(invites), total)
|
||
}
|
||
// Отказ по просрочке проходит — устаревшая строка и так мертва.
|
||
if err := repo.DeclineInvitation(ctx, p.ID, worker.ID); err != nil {
|
||
t.Errorf("decline expired: err = %v, want nil", err)
|
||
}
|
||
|
||
// Кому приглашения не было — отвечать нечего: 404.
|
||
if err := repo.AcceptInvitation(ctx, p.ID, other.ID); !errors.Is(err, ErrNotFound) {
|
||
t.Errorf("accept without invitation: err = %v, want ErrNotFound", err)
|
||
}
|
||
}
|
||
|
||
// TestListWorkersEmptyFallback — пустое окно списка состава: total
|
||
// досчитывается отдельным COUNT, а не остаётся нулём.
|
||
func TestListWorkersEmptyFallback(t *testing.T) {
|
||
q, ctx := newTestQuerier(t)
|
||
repo := NewProjectRepo(q)
|
||
|
||
owner := createProfile(t, ctx, q, "client")
|
||
worker := createProfile(t, ctx, q, "worker")
|
||
|
||
p := &models.Project{ClientID: owner.ID, Name: "ЖК Поле", IsActive: true}
|
||
if err := repo.CreateProject(ctx, p); err != nil {
|
||
t.Fatalf("create project: %v", err)
|
||
}
|
||
joinProject(t, ctx, repo, p.ID, worker.ID, owner.ID)
|
||
|
||
workers, total, err := repo.ListWorkers(ctx, p.ID, Page{Limit: MaxLimit, Offset: 100})
|
||
if err != nil {
|
||
t.Fatalf("list beyond the end: %v", err)
|
||
}
|
||
if len(workers) != 0 || total != 1 {
|
||
t.Errorf("page beyond the end = %d items/%d total, want 0 items/1 total", len(workers), total)
|
||
}
|
||
}
|
||
|
||
// joinProject — мастер входит в проект: с 0005 членство даёт только
|
||
// приглашение плюс согласие самого мастера (invite → accept).
|
||
func joinProject(t *testing.T, ctx context.Context, repo *ProjectRepo, projectID int64, workerID, invitedBy string) {
|
||
t.Helper()
|
||
if _, err := repo.InviteWorker(ctx, projectID, workerID, invitedBy); err != nil {
|
||
t.Fatalf("invite worker: %v", err)
|
||
}
|
||
if err := repo.AcceptInvitation(ctx, projectID, workerID); err != nil {
|
||
t.Fatalf("accept invitation: %v", err)
|
||
}
|
||
}
|
||
|
||
// expireInvitations — убить срок действия приглашения (для теста просрочки).
|
||
func expireInvitations(ctx context.Context, q Querier, projectID int64, workerID string) error {
|
||
_, err := q.Exec(ctx,
|
||
`UPDATE project_workers SET expires_at = now() - interval '1 minute'
|
||
WHERE worker_id = $1 AND project_id = $2`,
|
||
workerID, projectID)
|
||
return err
|
||
}
|
||
|
||
// TestRemoveWorkerBlockedByTickets — мастера нельзя убрать из проекта,
|
||
// пока за ним не закрыты заявки: иначе работа осталась бы без исполнителя.
|
||
// После закрытия отзыв проходит, история при этом не теряется.
|
||
func TestRemoveWorkerBlockedByTickets(t *testing.T) {
|
||
q, ctx := newTestQuerier(t)
|
||
repo := NewProjectRepo(q)
|
||
tickets := NewTicketRepo(q)
|
||
|
||
owner := createProfile(t, ctx, q, "client")
|
||
worker := createProfile(t, ctx, q, "worker")
|
||
|
||
projectID, objectID, address := createProjectObject(t, ctx, q, owner.ID)
|
||
joinProject(t, ctx, repo, projectID, worker.ID, owner.ID)
|
||
|
||
tk := &models.Ticket{
|
||
ClientID: owner.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 ticket: %v", err)
|
||
}
|
||
|
||
// Назначаем заявку мастеру: пока она не закрыта, убрать его нельзя —
|
||
// иначе работа осталась бы без исполнителя.
|
||
if err := tickets.Assign(ctx, tk.ID, worker.ID, worker.ID); err != nil {
|
||
t.Fatalf("assign: %v", err)
|
||
}
|
||
if err := repo.RemoveWorker(ctx, projectID, worker.ID); !errors.Is(err, ErrConflict) {
|
||
t.Errorf("remove with open ticket: err = %v, want ErrConflict", err)
|
||
}
|
||
|
||
// Закрываем заявку — отзыв разблокируется, заявка остаётся с мастером.
|
||
if err := tickets.Close(ctx, tk.ID, owner.ID, "передумал", []string{"new", "assigned", "done", "rejected"}); err != nil {
|
||
t.Fatalf("close: %v", err)
|
||
}
|
||
if err := repo.RemoveWorker(ctx, projectID, worker.ID); err != nil {
|
||
t.Errorf("remove after close: err = %v, want nil", err)
|
||
}
|
||
|
||
got, err := tickets.GetByID(ctx, tk.ID)
|
||
if err != nil {
|
||
t.Fatalf("get ticket: %v", err)
|
||
}
|
||
if got.WorkerID == nil || *got.WorkerID != worker.ID {
|
||
t.Errorf("worker_id = %v, want %s kept in history", got.WorkerID, worker.ID)
|
||
}
|
||
if got.ProjectID != projectID || got.ObjectID != objectID {
|
||
t.Errorf("project/object = %d/%d, want %d/%d", got.ProjectID, got.ObjectID, projectID, objectID)
|
||
}
|
||
|
||
// Неназначенная заявка (worker_id IS NULL) мастера не удерживает:
|
||
// исполнитель у неё и так не он. Членство набирается заново —
|
||
// invite → accept, строка новая (старую удалили).
|
||
joinProject(t, ctx, repo, projectID, worker.ID, owner.ID)
|
||
free := &models.Ticket{
|
||
ClientID: owner.ID,
|
||
ProjectID: projectID,
|
||
ObjectID: objectID,
|
||
Address: address,
|
||
Category: "other",
|
||
Description: "free ticket",
|
||
Priority: "normal",
|
||
}
|
||
if err := tickets.Create(ctx, free); err != nil {
|
||
t.Fatalf("create free ticket: %v", err)
|
||
}
|
||
if err := repo.RemoveWorker(ctx, projectID, worker.ID); err != nil {
|
||
t.Errorf("remove with only unassigned ticket: err = %v, want nil", err)
|
||
}
|
||
}
|
||
|
||
// TestAssignRequiresProjectMembership — «чужого» мастера не назначить:
|
||
// проверка стоит внутри UPDATE, поэтому обход хендлера не поможет.
|
||
func TestAssignRequiresProjectMembership(t *testing.T) {
|
||
q, ctx := newTestQuerier(t)
|
||
repo := NewProjectRepo(q)
|
||
tickets := NewTicketRepo(q)
|
||
|
||
owner := createProfile(t, ctx, q, "client")
|
||
worker := createProfile(t, ctx, q, "worker")
|
||
|
||
projectID, objectID, address := createProjectObject(t, ctx, q, owner.ID)
|
||
tk := &models.Ticket{
|
||
ClientID: owner.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 ticket: %v", err)
|
||
}
|
||
|
||
// Мастер не состоит в проекте — назначение не применяется.
|
||
if err := tickets.Assign(ctx, tk.ID, worker.ID, owner.ID); !errors.Is(err, ErrConflict) {
|
||
t.Errorf("assign foreign worker: err = %v, want ErrConflict", err)
|
||
}
|
||
got := mustGetTicket(t, ctx, tickets, tk.ID)
|
||
if got.WorkerID != nil || got.Status != "new" {
|
||
t.Errorf("ticket = %q/%v, want new/nil", got.Status, got.WorkerID)
|
||
}
|
||
|
||
// Приглашён, но не принял — всё равно не назначается: рубеж в самом
|
||
// UPDATE (status = 'active'), обход хендлера не поможет.
|
||
if _, err := repo.InviteWorker(ctx, projectID, worker.ID, owner.ID); err != nil {
|
||
t.Fatalf("invite: %v", err)
|
||
}
|
||
if err := tickets.Assign(ctx, tk.ID, worker.ID, owner.ID); !errors.Is(err, ErrConflict) {
|
||
t.Errorf("assign invited worker: err = %v, want ErrConflict", err)
|
||
}
|
||
got = mustGetTicket(t, ctx, tickets, tk.ID)
|
||
if got.WorkerID != nil || got.Status != "new" {
|
||
t.Errorf("ticket after invited assign = %q/%v, want new/nil", got.Status, got.WorkerID)
|
||
}
|
||
|
||
// Принял приглашение — назначение проходит.
|
||
if err := repo.AcceptInvitation(ctx, projectID, worker.ID); err != nil {
|
||
t.Fatalf("accept: %v", err)
|
||
}
|
||
if err := tickets.Assign(ctx, tk.ID, worker.ID, owner.ID); err != nil {
|
||
t.Fatalf("assign member: %v", err)
|
||
}
|
||
assigned := mustGetTicket(t, ctx, tickets, tk.ID)
|
||
if assigned.WorkerID == nil || *assigned.WorkerID != worker.ID {
|
||
t.Errorf("worker_id = %v, want %s", assigned.WorkerID, worker.ID)
|
||
}
|
||
}
|
||
|
||
// TestDeleteProjectCascade — удаление проекта: заявки, объекты, участия
|
||
// и журнал уходят каскадом (0004/0006/0008/0013), ключи фото возвращаются
|
||
// вызывающей стороне, повторное удаление — 404. Чужой проект не трогаем.
|
||
func TestDeleteProjectCascade(t *testing.T) {
|
||
q, ctx := newTestQuerier(t)
|
||
repo := NewProjectRepo(q)
|
||
tickets := NewTicketRepo(q)
|
||
photos := NewPhotoRepo(q)
|
||
|
||
owner := createProfile(t, ctx, q, "client")
|
||
worker := createProfile(t, ctx, q, "worker")
|
||
|
||
projectID, objectID, address := createProjectObject(t, ctx, q, owner.ID)
|
||
joinProject(t, ctx, repo, projectID, worker.ID, owner.ID)
|
||
|
||
tk := &models.Ticket{
|
||
ClientID: owner.ID,
|
||
ProjectID: projectID,
|
||
ObjectID: objectID,
|
||
Address: address,
|
||
Category: "other",
|
||
Description: "заявка проекта",
|
||
Priority: "normal",
|
||
}
|
||
if err := tickets.Create(ctx, tk); err != nil {
|
||
t.Fatalf("create ticket: %v", err)
|
||
}
|
||
if err := tickets.Assign(ctx, tk.ID, worker.ID, owner.ID); err != nil {
|
||
t.Fatalf("assign: %v", err)
|
||
}
|
||
// Фотоотчёт открывается только после старта работы (статус in_progress).
|
||
if err := tickets.Start(ctx, tk.ID, worker.ID); err != nil {
|
||
t.Fatalf("start: %v", err)
|
||
}
|
||
meta := photoMeta(tk.ID, worker.ID, 1, 10)
|
||
if _, err := photos.AddPhoto(ctx, meta, "worker"); err != nil {
|
||
t.Fatalf("add photo: %v", err)
|
||
}
|
||
|
||
// Соседний проект того же клиента: после удаления должен остаться.
|
||
neighbor := &models.Project{ClientID: owner.ID, Name: "Сосед", IsActive: true}
|
||
if err := repo.CreateProject(ctx, neighbor); err != nil {
|
||
t.Fatalf("create neighbor: %v", err)
|
||
}
|
||
|
||
// Удаление: возвращаются ключи объектов фото (их чистит хендлер),
|
||
// все вложенные строки исчезают каскадом.
|
||
keys, err := repo.DeleteProject(ctx, projectID)
|
||
if err != nil {
|
||
t.Fatalf("delete project: %v", err)
|
||
}
|
||
if len(keys) != 1 || keys[0] != meta.ObjectKey {
|
||
t.Errorf("keys = %v, want [%s]", keys, meta.ObjectKey)
|
||
}
|
||
|
||
if _, err := repo.OwnedProject(ctx, projectID, ""); !errors.Is(err, ErrNotFound) {
|
||
t.Errorf("project after delete: err = %v, want ErrNotFound", err)
|
||
}
|
||
if _, err := repo.OwnedObject(ctx, objectID, ""); !errors.Is(err, ErrNotFound) {
|
||
t.Errorf("object after delete: err = %v, want ErrNotFound", err)
|
||
}
|
||
if _, err := tickets.GetByID(ctx, tk.ID); !errors.Is(err, ErrNotFound) {
|
||
t.Errorf("ticket after delete: err = %v, want ErrNotFound", err)
|
||
}
|
||
if status, err := repo.MembershipStatus(ctx, worker.ID, projectID); err != nil || status != "" {
|
||
t.Errorf("membership after delete: status=%q err=%v, want empty/nil", status, err)
|
||
}
|
||
// Строки фото и событий журнала ушли каскадом вместе с заявкой.
|
||
var photosLeft, eventsLeft int
|
||
if err := q.QueryRow(ctx,
|
||
`SELECT COUNT(*) FROM ticket_photos WHERE ticket_id = $1`, tk.ID).Scan(&photosLeft); err != nil {
|
||
t.Fatalf("count photos: %v", err)
|
||
}
|
||
if err := q.QueryRow(ctx,
|
||
`SELECT COUNT(*) FROM ticket_events WHERE ticket_id = $1`, tk.ID).Scan(&eventsLeft); err != nil {
|
||
t.Fatalf("count events: %v", err)
|
||
}
|
||
if photosLeft != 0 || eventsLeft != 0 {
|
||
t.Errorf("photo rows = %d, event rows = %d, want 0/0", photosLeft, eventsLeft)
|
||
}
|
||
|
||
// Повторное удаление — проект не существует (404).
|
||
if _, err := repo.DeleteProject(ctx, projectID); !errors.Is(err, ErrNotFound) {
|
||
t.Errorf("second delete: err = %v, want ErrNotFound", err)
|
||
}
|
||
// Чужой проект не пострадал.
|
||
if _, err := repo.OwnedProject(ctx, neighbor.ID, owner.ID); err != nil {
|
||
t.Errorf("neighbor project: err = %v, want nil", err)
|
||
}
|
||
}
|
||
|
||
func projectIDs(projects []models.Project) []int64 {
|
||
out := make([]int64, 0, len(projects))
|
||
for _, p := range projects {
|
||
out = append(out, p.ID)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func workerIDs(workers []models.ProjectWorker) []string {
|
||
out := make([]string, 0, len(workers))
|
||
for _, w := range workers {
|
||
out = append(out, w.ID)
|
||
}
|
||
return out
|
||
}
|