package repository import ( "errors" "testing" "time" "github.com/google/uuid" "building-mgmt/internal/models" ) // TestProfileEmailNormalization — email хранится и ищется без учёта регистра // (индекс profiles_email_lower_key из миграции 0003). func TestProfileEmailNormalization(t *testing.T) { q, ctx := newTestQuerier(t) repo := NewProfileRepo(q) p := &models.Profile{ ID: uuid.NewString(), Email: " Mixed.Case@Example.TEST ", PasswordHash: "hash", Name: "mixed", Role: "client", IsActive: true, } if err := repo.Create(ctx, p); err != nil { t.Fatalf("create: %v", err) } if p.Email != "mixed.case@example.test" { t.Errorf("stored email = %q, want normalized %q", p.Email, "mixed.case@example.test") } // Поиск с другим регистром находит того же пользователя. got, err := repo.GetByEmail(ctx, "MIXED.CASE@EXAMPLE.test") if err != nil { t.Fatalf("get by email (other case): %v", err) } if got.ID != p.ID { t.Errorf("got profile %s, want %s", got.ID, p.ID) } // Дубликат в другом регистре — тот же адрес, 409. dup := &models.Profile{ ID: uuid.NewString(), Email: "mixed.case@example.test", PasswordHash: "hash", Name: "dup", Role: "client", IsActive: true, } expectError(t, ctx, q, "duplicate email (other case)", func() error { return repo.Create(ctx, dup) }, ErrConflict) // Неизвестный адрес — не 409, а ровно «не найдено». if _, err := repo.GetByEmail(ctx, "nobody@example.test"); !errors.Is(err, ErrNotFound) { t.Errorf("unknown email: err = %v, want ErrNotFound", err) } } // TestSearchByRole — поиск мастера для приглашения в проект: подстрока // без учёта регистра, спецсимволы ILIKE ищутся буквально, выключенные // аккаунты и другие роли в результат не попадают. func TestSearchByRole(t *testing.T) { q, ctx := newTestQuerier(t) repo := NewProfileRepo(q) pro := func(name, email, role string, active bool) { t.Helper() p := &models.Profile{ ID: uuid.NewString(), Email: email, PasswordHash: "test-hash", Name: name, Role: role, IsActive: active, } if err := repo.Create(ctx, p); err != nil { t.Fatalf("create %s: %v", email, err) } } pro("Петр Сидоров", "sidorov@site.test", "worker", true) pro("Petr Upper", "petr-upper@site.test", "worker", true) pro("Петр Выкл", "petr-off@site.test", "worker", false) // выключен — мимо pro("Петр Клиент", "petr-client@site.test", "client", true) // чужая роль — мимо pro("Мастер 100%", "percent@site.test", "worker", true) t.Run("кириллица без учёта регистра", func(t *testing.T) { got, total, err := repo.SearchByRole(ctx, "worker", "петр", false, Page{Limit: MaxLimit}) if err != nil { t.Fatalf("search: %v", err) } if total != 1 || len(got) != 1 || got[0].Email != "sidorov@site.test" { t.Errorf("got %d/%d %+v, want ровно sidorov (выключенный и клиент скрыты)", len(got), total, got) } }) t.Run("латиница в другом регистре", func(t *testing.T) { got, total, err := repo.SearchByRole(ctx, "worker", "PETR-UPPER", false, Page{Limit: MaxLimit}) if err != nil { t.Fatalf("search: %v", err) } if total != 1 || len(got) != 1 || got[0].Name != "Petr Upper" { t.Errorf("got %d/%d %+v, want Petr Upper", len(got), total, got) } }) t.Run("процент в запросе ищется буквально", func(t *testing.T) { got, total, err := repo.SearchByRole(ctx, "worker", "100%", false, Page{Limit: MaxLimit}) if err != nil { t.Fatalf("search: %v", err) } // Без экранирования шаблон %100%% совпал бы со всеми подряд. if total != 1 || len(got) != 1 || got[0].Name != "Мастер 100%" { t.Errorf("got %d/%d %+v, want «Мастер 100%%»", len(got), total, got) } }) t.Run("пустое окно считает total", func(t *testing.T) { got, total, err := repo.SearchByRole(ctx, "worker", "петр", false, Page{Limit: MaxLimit, Offset: 100}) if err != nil { t.Fatalf("search: %v", err) } if len(got) != 0 || total != 1 { t.Errorf("got %d строк total=%d, want 0 строк total=1", len(got), total) } }) t.Run("ничего не найдено", func(t *testing.T) { got, total, err := repo.SearchByRole(ctx, "worker", "нет-такого-мастера", false, Page{Limit: MaxLimit}) if err != nil { t.Fatalf("search: %v", err) } if len(got) != 0 || total != 0 { t.Errorf("got %d строк total=%d, want пусто", len(got), total) } }) } func TestProfileSetActive(t *testing.T) { q, ctx := newTestQuerier(t) repo := NewProfileRepo(q) p := createProfile(t, ctx, q, "worker") if err := repo.SetActive(ctx, p.ID, false); err != nil { t.Fatalf("deactivate: %v", err) } got, err := repo.GetByID(ctx, p.ID) if err != nil { t.Fatalf("get: %v", err) } if got.IsActive { t.Error("is_active = true after deactivation") } if err := repo.SetActive(ctx, p.ID, true); err != nil { t.Fatalf("activate: %v", err) } if got, _ := repo.GetByID(ctx, p.ID); !got.IsActive { t.Error("is_active = false after activation") } if err := repo.SetActive(ctx, uuid.NewString(), false); !errors.Is(err, ErrNotFound) { t.Errorf("unknown id: err = %v, want ErrNotFound", err) } } // --- Удаление профиля и клиентское отключение --- // TestDeleteProfileRules — «чистый» профиль удаляется, причём его // refresh-сессии уходят каскадом (миграция 0003). Профиль, на который // ссылаются проекты или участия (FK без каскада), БД не отдаёт: // ErrUserHasData → 409 «отключите вместо удаления». Несуществующий id — // ErrNotFound (404). func TestDeleteProfileRules(t *testing.T) { q, ctx := newTestQuerier(t) profiles := NewProfileRepo(q) projects := NewProjectRepo(q) tokens := NewRefreshTokenRepo(q) // Чистый: сам профиль + одна живая сессия — уходят обе строки. clean := createProfile(t, ctx, q, "worker") hash := "hash-" + uuid.NewString() if err := tokens.Create(ctx, uuid.NewString(), clean.ID, hash, time.Now().Add(time.Hour), "ua", "127.0.0.1"); err != nil { t.Fatalf("create refresh token: %v", err) } if err := profiles.DeleteProfile(ctx, clean.ID); err != nil { t.Fatalf("clean delete: %v", err) } if _, err := profiles.GetByID(ctx, clean.ID); !errors.Is(err, ErrNotFound) { t.Errorf("после удаления: err = %v, want ErrNotFound", err) } if _, err := tokens.GetByHash(ctx, hash); !errors.Is(err, ErrNotFound) { t.Errorf("сессия удалённого: err = %v, want ErrNotFound (каскад)", err) } if err := profiles.DeleteProfile(ctx, uuid.NewString()); !errors.Is(err, ErrNotFound) { t.Errorf("несуществующий id: err = %v, want ErrNotFound", err) } // Клиент с проектом: FK без каскада не даёт стереть владельца. owner := createProfile(t, ctx, q, "client") p := &models.Project{ClientID: owner.ID, Name: "ЖК Отказ", IsActive: true} if err := projects.CreateProject(ctx, p); err != nil { t.Fatalf("create project: %v", err) } expectError(t, ctx, q, "delete client with project", func() error { return profiles.DeleteProfile(ctx, owner.ID) }, ErrUserHasData) // Мастер с активным членством — тоже история, а не «чистый» аккаунт. worker := createProfile(t, ctx, q, "worker") if _, err := projects.InviteWorker(ctx, p.ID, worker.ID, owner.ID); err != nil { t.Fatalf("invite worker: %v", err) } if err := projects.AcceptInvitation(ctx, p.ID, worker.ID); err != nil { t.Fatalf("accept invitation: %v", err) } expectError(t, ctx, q, "delete worker with membership", func() error { return profiles.DeleteProfile(ctx, worker.ID) }, ErrUserHasData) } // TestSetActiveWorkerForClientRules — правило клиента целиком в SQL: // включить/выключить можно только мастера, активного участника своих // проектов. До принятия приглашения мастер ещё «не свой»; чужой мастер, // другой клиент и несуществующий id неразличимы — все ErrNotFound. func TestSetActiveWorkerForClientRules(t *testing.T) { q, ctx := newTestQuerier(t) profiles := NewProfileRepo(q) projects := NewProjectRepo(q) owner := createProfile(t, ctx, q, "client") stranger := createProfile(t, ctx, q, "client") worker := createProfile(t, ctx, q, "worker") bystander := createProfile(t, ctx, q, "worker") p := &models.Project{ClientID: owner.ID, Name: "ЖК Правило", IsActive: true} if err := projects.CreateProject(ctx, p); err != nil { t.Fatalf("create project: %v", err) } if _, err := projects.InviteWorker(ctx, p.ID, worker.ID, owner.ID); err != nil { t.Fatalf("invite worker: %v", err) } // Приглашение не принято — активного членства нет, трогать пока нечего. if err := profiles.SetActiveWorkerForClient(ctx, worker.ID, owner.ID, false); !errors.Is(err, ErrNotFound) { t.Errorf("pending invitation: err = %v, want ErrNotFound", err) } if err := projects.AcceptInvitation(ctx, p.ID, worker.ID); err != nil { t.Fatalf("accept invitation: %v", err) } // Свой мастер: выключается, повторный флаг возвращает — включается. if err := profiles.SetActiveWorkerForClient(ctx, worker.ID, owner.ID, false); err != nil { t.Fatalf("deactivate own worker: %v", err) } if got, err := profiles.GetByID(ctx, worker.ID); err != nil || got.IsActive { t.Errorf("после выключения: is_active=%v err=%v, want false/nil", got.IsActive, err) } if err := profiles.SetActiveWorkerForClient(ctx, worker.ID, owner.ID, true); err != nil { t.Fatalf("reactivate own worker: %v", err) } if got, err := profiles.GetByID(ctx, worker.ID); err != nil || !got.IsActive { t.Errorf("после включения: is_active=%v err=%v, want true/nil", got.IsActive, err) } // Созданный мной, но ещё не приглашённый: гвард «свой» ровно тот же, // что в списке ListWorkersForClient — кнопка в его строке не тупиковая. created := &models.Profile{ ID: uuid.NewString(), Email: uuid.NewString() + "@example.test", PasswordHash: "hash", Name: "created-worker", Role: "worker", IsActive: true, } if err := profiles.CreateBy(ctx, created, owner.ID); err != nil { t.Fatalf("create by owner: %v", err) } if err := profiles.SetActiveWorkerForClient(ctx, created.ID, owner.ID, false); err != nil { t.Fatalf("deactivate created (not member) worker: %v", err) } if err := profiles.SetActiveWorkerForClient(ctx, created.ID, stranger.ID, false); !errors.Is(err, ErrNotFound) { t.Errorf("чужой клиент жмёт созданного: err = %v, want ErrNotFound", err) } if err := profiles.SetActiveWorkerForClient(ctx, created.ID, owner.ID, true); err != nil { t.Fatalf("reactivate created worker: %v", err) } // Границы: чужой мастер, другой клиент, чужая роль, несуществующий id. for _, tc := range []struct { label, target, client string }{ {"мастер вне проектов", bystander.ID, owner.ID}, {"чужой клиент жмёт нашего мастера", worker.ID, stranger.ID}, {"клиент жмёт профиль клиента", stranger.ID, owner.ID}, {"несуществующий id", uuid.NewString(), owner.ID}, } { if err := profiles.SetActiveWorkerForClient(ctx, tc.target, tc.client, false); !errors.Is(err, ErrNotFound) { t.Errorf("%s: err = %v, want ErrNotFound", tc.label, err) } } } // TestDeleteWorkerForClientRules — клиентское удаление мастера целиком // в SQL-транзакции: граница «состоит в моём проекте» (чужой мастер и // несуществующий id неразличимы), членства удаляются только в моих // проектах, а FK-отказ от чужих проектов откатывает всё: профиль и все // членства остаются на месте → ErrUserHasData (409 «отключите вместо // удаления»). func TestDeleteWorkerForClientRules(t *testing.T) { q, ctx := newTestQuerier(t) profiles := NewProfileRepo(q) projects := NewProjectRepo(q) owner := createProfile(t, ctx, q, "client") stranger := createProfile(t, ctx, q, "client") bystander := createProfile(t, ctx, q, "worker") wInvite := createProfile(t, ctx, q, "worker") wBusy := createProfile(t, ctx, q, "worker") memberships := func(workerID string) int { t.Helper() var n int if err := q.QueryRow(ctx, `SELECT COUNT(*) FROM project_workers WHERE worker_id = $1`, workerID).Scan(&n); err != nil { t.Fatalf("count memberships: %v", err) } return n } p := &models.Project{ClientID: owner.ID, Name: "ЖК Удаление", IsActive: true} if err := projects.CreateProject(ctx, p); err != nil { t.Fatalf("create project: %v", err) } // Границы до членств: чужой и несуществующий неразличимы. for _, tc := range []struct{ label, target, client string }{ {"несуществующий id", uuid.NewString(), owner.ID}, {"мастер вне проектов", bystander.ID, owner.ID}, {"клиент удаляет профиль клиента", stranger.ID, owner.ID}, } { if err := profiles.DeleteWorkerForClient(ctx, tc.target, tc.client); !errors.Is(err, ErrNotFound) { t.Errorf("%s: err = %v, want ErrNotFound", tc.label, err) } } // Приглашение не принято — но членство уже есть, значит «свой»: // аккаунт и членство уходят одной транзакцией. if _, err := projects.InviteWorker(ctx, p.ID, wInvite.ID, owner.ID); err != nil { t.Fatalf("invite worker: %v", err) } if err := profiles.DeleteWorkerForClient(ctx, wInvite.ID, stranger.ID); !errors.Is(err, ErrNotFound) { t.Errorf("чужой клиент: err = %v, want ErrNotFound", err) } if err := profiles.DeleteWorkerForClient(ctx, wInvite.ID, owner.ID); err != nil { t.Fatalf("delete own invited worker: %v", err) } if _, err := profiles.GetByID(ctx, wInvite.ID); !errors.Is(err, ErrNotFound) { t.Errorf("после удаления: err = %v, want ErrNotFound", err) } if n := memberships(wInvite.ID); n != 0 { t.Errorf("членство удалённого: %d строк, want 0", n) } // Созданный мной, но ещё не приглашённый: «свой» — удаляется // (членств нет, шаг 2 пустой), чужой клиент — 404. created := &models.Profile{ ID: uuid.NewString(), Email: uuid.NewString() + "@example.test", PasswordHash: "hash", Name: "created-worker", Role: "worker", IsActive: true, } if err := profiles.CreateBy(ctx, created, owner.ID); err != nil { t.Fatalf("create by owner: %v", err) } if err := profiles.DeleteWorkerForClient(ctx, created.ID, stranger.ID); !errors.Is(err, ErrNotFound) { t.Errorf("чужой клиент удаляет созданного: err = %v, want ErrNotFound", err) } if err := profiles.DeleteWorkerForClient(ctx, created.ID, owner.ID); err != nil { t.Fatalf("delete created (not member) worker: %v", err) } if _, err := profiles.GetByID(ctx, created.ID); !errors.Is(err, ErrNotFound) { t.Errorf("после удаления созданного: err = %v, want ErrNotFound", err) } // Мастер состоит и в моём, и в чужом проекте: шаг 2 убирает моё // членство, но FK от чужого даёт 23503 → откат всей транзакции — // профиль и оба членства остаются. if _, err := projects.InviteWorker(ctx, p.ID, wBusy.ID, owner.ID); err != nil { t.Fatalf("invite worker (mine): %v", err) } if err := projects.AcceptInvitation(ctx, p.ID, wBusy.ID); err != nil { t.Fatalf("accept invitation: %v", err) } sp := &models.Project{ClientID: stranger.ID, Name: "Чужой ЖК", IsActive: true} if err := projects.CreateProject(ctx, sp); err != nil { t.Fatalf("create stranger project: %v", err) } if _, err := projects.InviteWorker(ctx, sp.ID, wBusy.ID, stranger.ID); err != nil { t.Fatalf("invite worker (foreign): %v", err) } expectError(t, ctx, q, "delete worker with foreign membership", func() error { return profiles.DeleteWorkerForClient(ctx, wBusy.ID, owner.ID) }, ErrUserHasData) if _, err := profiles.GetByID(ctx, wBusy.ID); err != nil { t.Errorf("профиль после отката: err = %v, want существующий", err) } if n := memberships(wBusy.ID); n != 2 { t.Errorf("членств после отката: %d, want 2 (моё и чужое)", n) } // Уже удалённый — тот же 404, что и чужой. if err := profiles.DeleteWorkerForClient(ctx, wInvite.ID, owner.ID); !errors.Is(err, ErrNotFound) { t.Errorf("повторное удаление: err = %v, want ErrNotFound", err) } } // TestListByRoleIncludeInactive — include_inactive показывает и // выключенных (нужно админу: кнопка «Включить» после отключения не // должна быть тупиком); по умолчанию — только активные. func TestListByRoleIncludeInactive(t *testing.T) { q, ctx := newTestQuerier(t) repo := NewProfileRepo(q) active := createProfile(t, ctx, q, "worker") off := createProfile(t, ctx, q, "worker") if err := repo.SetActive(ctx, off.ID, false); err != nil { t.Fatalf("deactivate: %v", err) } // В базе есть и другие мастера (посев), поэтому проверяем конкретные // id на страницах, а не абсолютные счётчики. got, _, err := repo.ListByRole(ctx, "worker", false, Page{Limit: MaxLimit}) if err != nil { t.Fatalf("list active only: %v", err) } if containsID(got, off.ID) { t.Error("без флага выключенный мастер не должен попадать в список") } if !containsID(got, active.ID) { t.Error("активный мастер пропал из списка без флага") } got, _, err = repo.ListByRole(ctx, "worker", true, Page{Limit: MaxLimit}) if err != nil { t.Fatalf("list with inactive: %v", err) } if !containsID(got, active.ID) || !containsID(got, off.ID) { t.Error("с флагом должны быть оба: активный и выключенный") } } // containsID — есть ли профиль с данным id на странице. func containsID(profiles []models.Profile, id string) bool { for _, p := range profiles { if p.ID == id { return true } } return false } // TestRefreshTokens — жизнь сессии: выпуск, поиск по хешу, отзыв одной, // отзыв всех и чистка протухших. func TestRefreshTokens(t *testing.T) { q, ctx := newTestQuerier(t) repo := NewRefreshTokenRepo(q) profile := createProfile(t, ctx, q, "client") hash := uuid.NewString // «хеш» — достаточно уникальной строки t.Run("выпуск и поиск по хешу", func(t *testing.T) { h := hash() if err := repo.Create(ctx, uuid.NewString(), profile.ID, h, time.Now().Add(time.Hour), "test-agent", "127.0.0.1"); err != nil { t.Fatalf("create: %v", err) } got, err := repo.GetByHash(ctx, h) if err != nil { t.Fatalf("get by hash: %v", err) } if got.UserID != profile.ID { t.Errorf("user_id = %s, want %s", got.UserID, profile.ID) } if got.RevokedAt != nil { t.Error("fresh token is revoked") } if got.UserAgent != "test-agent" || got.IP != "127.0.0.1" { t.Errorf("agent=%q ip=%q", got.UserAgent, got.IP) } if _, err := repo.GetByHash(ctx, hash()); !errors.Is(err, ErrNotFound) { t.Errorf("unknown hash: err = %v, want ErrNotFound", err) } }) t.Run("отзыв идемпотентен", func(t *testing.T) { p := createProfile(t, ctx, q, "client") h := hash() if err := repo.Create(ctx, uuid.NewString(), p.ID, h, time.Now().Add(time.Hour), "", ""); err != nil { t.Fatalf("create: %v", err) } ok, err := repo.RevokeByHash(ctx, h) if err != nil || !ok { t.Fatalf("first revoke: ok=%v err=%v", ok, err) } ok, err = repo.RevokeByHash(ctx, h) if err != nil || ok { t.Errorf("second revoke: ok=%v err=%v, want false,nil", ok, err) } got, err := repo.GetByHash(ctx, h) if err != nil { t.Fatalf("get: %v", err) } if got.RevokedAt == nil { t.Error("revoked_at not set") } }) t.Run("revoked_at не трогает активные", func(t *testing.T) { // Своя пара «клиент — сессии», чтобы прошлые подтесты не влияли // на счётчик: RevokeAllForUser считает только активные записи. p := createProfile(t, ctx, q, "client") live := hash() if err := repo.Create(ctx, uuid.NewString(), p.ID, live, time.Now().Add(time.Hour), "", ""); err != nil { t.Fatalf("create live: %v", err) } dead := hash() if err := repo.Create(ctx, uuid.NewString(), p.ID, dead, time.Now().Add(time.Hour), "", ""); err != nil { t.Fatalf("create dead: %v", err) } if _, err := repo.RevokeByHash(ctx, dead); err != nil { t.Fatalf("revoke: %v", err) } n, err := repo.RevokeAllForUser(ctx, p.ID) if err != nil { t.Fatalf("revoke all: %v", err) } if n != 1 { t.Errorf("revoked %d session(s), want 1 (уже отозванная не считается)", n) } if got, _ := repo.GetByHash(ctx, live); got.RevokedAt == nil { t.Error("live token not revoked by logout-all") } }) t.Run("чистка протухших", func(t *testing.T) { expired := hash() if err := repo.Create(ctx, uuid.NewString(), profile.ID, expired, time.Now().Add(-time.Hour), "", ""); err != nil { t.Fatalf("create expired: %v", err) } fresh := hash() if err := repo.Create(ctx, uuid.NewString(), profile.ID, fresh, time.Now().Add(time.Hour), "", ""); err != nil { t.Fatalf("create fresh: %v", err) } removed, err := repo.DeleteExpired(ctx) if err != nil { t.Fatalf("delete expired: %v", err) } if removed < 1 { t.Errorf("removed %d, want at least the expired one", removed) } if _, err := repo.GetByHash(ctx, expired); !errors.Is(err, ErrNotFound) { t.Errorf("expired token survived: err = %v", err) } if _, err := repo.GetByHash(ctx, fresh); err != nil { t.Errorf("fresh token removed: %v", err) } }) } // TestUserConfirmationRules — матрица подтверждений (миграция 0010): // - вставка без актора (Create) — pending; // - создание админом (CreateBy) — подтверждение сразу, тем же INSERT; // - создание клиентом — pending, ждёт подтверждения этим клиентом; // - подтверждает админ (любого) или клиент (только созданного им мастера); // - повтор — ErrAlreadyConfirmed, чужой/неподходящий/неизвестный — 404. func TestUserConfirmationRules(t *testing.T) { q, ctx := newTestQuerier(t) repo := NewProfileRepo(q) mkProfile := func(role string) *models.Profile { return &models.Profile{ ID: uuid.NewString(), Email: uuid.NewString() + "@example.test", PasswordHash: "hash", Name: role + "-pending", Role: role, IsActive: true, } } admin := createProfile(t, ctx, q, "admin") client := createProfile(t, ctx, q, "client") otherClient := createProfile(t, ctx, q, "client") // Вставка без актора — подтверждения нет. noActor := mkProfile("client") if err := repo.Create(ctx, noActor); err != nil { t.Fatalf("create without actor: %v", err) } if noActor.ConfirmedAt != nil { t.Error("create without actor must be pending") } // Создание админом = подтверждение сразу («создатель и есть подтверждающий»). byAdmin := mkProfile("worker") if err := repo.CreateBy(ctx, byAdmin, admin.ID); err != nil { t.Fatalf("create by admin: %v", err) } if byAdmin.ConfirmedAt == nil { t.Error("admin-created worker must be confirmed at creation") } // Создание клиентом — pending: клиент подтвердит сам. byClient := mkProfile("worker") if err := repo.CreateBy(ctx, byClient, client.ID); err != nil { t.Fatalf("create by client: %v", err) } if byClient.ConfirmedAt != nil { t.Error("client-created worker must be pending") } // Админ подтверждает профиль без создателя, confirmed_by = админ. if err := repo.ConfirmUser(ctx, noActor.ID, admin.ID); err != nil { t.Fatalf("admin confirms: %v", err) } got, err := repo.GetByID(ctx, noActor.ID) if err != nil { t.Fatalf("reread: %v", err) } if got.ConfirmedAt == nil { t.Error("must be confirmed after ConfirmUser") } var confirmedBy string if err := q.QueryRow(ctx, `SELECT confirmed_by FROM profiles WHERE id = $1`, noActor.ID, ).Scan(&confirmedBy); err != nil { t.Fatalf("read confirmed_by: %v", err) } if confirmedBy != admin.ID { t.Errorf("confirmed_by = %s, want %s", confirmedBy, admin.ID) } // Повторное подтверждение — 409. expectError(t, ctx, q, "confirm twice", func() error { return repo.ConfirmUser(ctx, noActor.ID, admin.ID) }, ErrAlreadyConfirmed) // Клиент подтверждает созданного им мастера. if err := repo.ConfirmUser(ctx, byClient.ID, client.ID); err != nil { t.Fatalf("client confirms own worker: %v", err) } // Чужого (не созданного этим клиентом) мастера клиент не подтверждает. foreign := mkProfile("worker") if err := repo.Create(ctx, foreign); err != nil { t.Fatalf("foreign worker: %v", err) } expectError(t, ctx, q, "client confirms foreign worker", func() error { return repo.ConfirmUser(ctx, foreign.ID, client.ID) }, ErrNotFound) // Другого клиента клиент не подтверждает (роль не worker → 404). expectError(t, ctx, q, "client confirms another client", func() error { return repo.ConfirmUser(ctx, otherClient.ID, client.ID) }, ErrNotFound) // Мастер не подтверждает никого — 404. expectError(t, ctx, q, "worker confirms someone", func() error { return repo.ConfirmUser(ctx, foreign.ID, byClient.ID) }, ErrNotFound) // Несуществующий id — 404, а не ошибка типа или 409. expectError(t, ctx, q, "confirm unknown id", func() error { return repo.ConfirmUser(ctx, uuid.NewString(), admin.ID) }, ErrNotFound) }