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 }