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

585 lines
24 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"
"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
}