Files
paliad/internal/services/user_service_test.go
m 7c44bbec7e refactor: onboarding form — drop Praxisgruppe, free-text role, add Dezernat (t-paliad-020)
- Drop the Praxisgruppe field from the onboarding form. Every Paliad user
  is in patent practice, so the field carried no signal. The DB column is
  retained for future use (set to NULL on insert).
- Switch role from a 4-value enum (partner/associate/pa/admin) to free
  text with a <datalist> of suggestions (Partner, Associate, PA, Of
  Counsel, Referendar/in, Trainee, wiss. Mitarbeiter/in, Sekretariat).
  German firms have many roles beyond the original four.
- Add an optional Dezernat field — the team led by a specific partner.
  Free text, no FK (the partner may not be registered yet).

Backend:
- Migration 015: drop the role enum CHECK, replace with non-empty CHECK;
  ADD COLUMN dezernat text.
- UserService.Create: drop validRoles map, require non-empty role string,
  trim and persist Dezernat. Admin bootstrap gate unchanged.
- models.User gains Dezernat *string; userColumns SELECT updated so
  /api/me returns it.

Frontend:
- onboarding.tsx: replace role <select> with <input list=...>; add
  dezernat input; remove practice_group.
- onboarding.ts: send dezernat (if non-empty), require role.
- i18n: add onboarding.role.placeholder, onboarding.dezernat[.placeholder],
  onboarding.error.role; remove the role.* enum and practice_group keys.
2026-04-18 20:26:11 +02:00

168 lines
5.1 KiB
Go

package services
import (
"context"
"errors"
"os"
"testing"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"mgit.msbls.de/m/patholo/internal/db"
)
// user_service_test covers the onboarding Create path end-to-end against a
// real Postgres. Mirrors the akte_service_test setup pattern; skips when
// TEST_DATABASE_URL is unset.
func setupUserTest(t *testing.T) (*UserService, *sqlx.DB, func()) {
t.Helper()
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
}
if err := db.ApplyMigrations(url); err != nil {
t.Fatalf("apply migrations: %v", err)
}
pool, err := sqlx.Connect("postgres", url)
if err != nil {
t.Fatalf("connect: %v", err)
}
users := NewUserService(pool)
return users, pool, func() { pool.Close() }
}
func seedAuthUser(t *testing.T, pool *sqlx.DB, id uuid.UUID, email string) {
t.Helper()
if _, err := pool.ExecContext(context.Background(),
`INSERT INTO auth.users (id, email) VALUES ($1, $2)
ON CONFLICT (id) DO UPDATE SET email = EXCLUDED.email`, id, email); err != nil {
t.Fatalf("seed auth.users: %v", err)
}
}
func cleanupUsers(t *testing.T, pool *sqlx.DB, ids ...uuid.UUID) {
t.Helper()
ctx := context.Background()
for _, id := range ids {
pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, id)
pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, id)
}
}
func TestUserService_Create_Valid(t *testing.T) {
users, pool, done := setupUserTest(t)
defer done()
// Ensure the table is empty so the bootstrap-admin gate is deterministic.
pool.ExecContext(context.Background(), `DELETE FROM paliad.users`)
id := uuid.MustParse("aaaaaaaa-0000-0000-0000-000000000001")
seedAuthUser(t, pool, id, "first@hlc.com")
defer cleanupUsers(t, pool, id)
dezernat := " Team M\u00fcller "
u, err := users.Create(context.Background(), id, "first@hlc.com", CreateUserInput{
DisplayName: " First User ",
Office: "munich",
Role: "Trainee",
Dezernat: &dezernat,
})
if err != nil {
t.Fatalf("create: %v", err)
}
if u == nil || u.ID != id {
t.Fatalf("unexpected user: %+v", u)
}
if u.DisplayName != "First User" {
t.Errorf("display_name not trimmed: %q", u.DisplayName)
}
if u.Office != "munich" || u.Role != "Trainee" || u.Email != "first@hlc.com" {
t.Errorf("field mismatch: %+v", u)
}
if u.Dezernat == nil || *u.Dezernat != "Team M\u00fcller" {
t.Errorf("dezernat not trimmed/persisted: %+v", u.Dezernat)
}
}
func TestUserService_Create_InvalidInput(t *testing.T) {
users, pool, done := setupUserTest(t)
defer done()
id := uuid.MustParse("aaaaaaaa-0000-0000-0000-000000000002")
seedAuthUser(t, pool, id, "x@hlc.com")
defer cleanupUsers(t, pool, id)
cases := []struct {
name string
input CreateUserInput
}{
{"missing display_name", CreateUserInput{Office: "munich", Role: "associate"}},
{"invalid office", CreateUserInput{DisplayName: "X", Office: "tokyo", Role: "associate"}},
{"missing role", CreateUserInput{DisplayName: "X", Office: "munich", Role: " "}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if _, err := users.Create(context.Background(), id, "x@hlc.com", c.input); err == nil {
t.Fatalf("expected error")
}
})
}
}
func TestUserService_Create_DuplicateReturns409(t *testing.T) {
users, pool, done := setupUserTest(t)
defer done()
id := uuid.MustParse("aaaaaaaa-0000-0000-0000-000000000003")
seedAuthUser(t, pool, id, "dup@hlc.com")
defer cleanupUsers(t, pool, id)
ctx := context.Background()
in := CreateUserInput{DisplayName: "Dup", Office: "munich", Role: "associate"}
if _, err := users.Create(ctx, id, "dup@hlc.com", in); err != nil {
t.Fatalf("first create: %v", err)
}
_, err := users.Create(ctx, id, "dup@hlc.com", in)
if !errors.Is(err, ErrUserAlreadyOnboarded) {
t.Fatalf("expected ErrUserAlreadyOnboarded, got %v", err)
}
}
func TestUserService_Create_AdminBootstrapRespected(t *testing.T) {
users, pool, done := setupUserTest(t)
defer done()
ctx := context.Background()
// Start with an empty table so the first admin is the bootstrap admin.
pool.ExecContext(ctx, `DELETE FROM paliad.users`)
first := uuid.MustParse("aaaaaaaa-0000-0000-0000-000000000011")
second := uuid.MustParse("aaaaaaaa-0000-0000-0000-000000000012")
seedAuthUser(t, pool, first, "first@hlc.com")
seedAuthUser(t, pool, second, "second@hlc.com")
defer cleanupUsers(t, pool, first, second)
if _, err := users.Create(ctx, first, "first@hlc.com", CreateUserInput{
DisplayName: "First", Office: "munich", Role: "admin",
}); err != nil {
t.Fatalf("bootstrap admin: %v", err)
}
_, err := users.Create(ctx, second, "second@hlc.com", CreateUserInput{
DisplayName: "Second", Office: "munich", Role: "admin",
})
if !errors.Is(err, ErrAdminBootstrapOnly) {
t.Fatalf("expected ErrAdminBootstrapOnly, got %v", err)
}
// Non-admin role still works for the second user.
if _, err := users.Create(ctx, second, "second@hlc.com", CreateUserInput{
DisplayName: "Second", Office: "munich", Role: "associate",
}); err != nil {
t.Fatalf("second user as associate: %v", err)
}
}