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

685 lines
27 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 (
"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)
}