- 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.
168 lines
5.1 KiB
Go
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)
|
|
}
|
|
}
|