axe/internal/repository/profile_test.go

685 lines
27 KiB
Go
Raw Permalink Normal View History

2026-10-18 19:07:25 +00:00
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)
}