package services import ( "context" "errors" "os" "testing" "github.com/google/uuid" "github.com/jmoiron/sqlx" _ "github.com/lib/pq" "mgit.msbls.de/m/paliad/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 path is deterministic. The // first inserter becomes global_admin; this test just exercises the rest // of the Create shape and isn't asserting on global_role. 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) u, err := users.Create(context.Background(), id, "first@hlc.com", CreateUserInput{ DisplayName: " First User ", Office: "munich", JobTitle: "Trainee", }) 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.JobTitle == nil || *u.JobTitle != "Trainee" || u.Email != "first@hlc.com" { t.Errorf("field mismatch: %+v", u) } } 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", JobTitle: "associate"}}, {"invalid office", CreateUserInput{DisplayName: "X", Office: "tokyo", JobTitle: "associate"}}, {"missing job_title", CreateUserInput{DisplayName: "X", Office: "munich", JobTitle: " "}}, } 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", JobTitle: "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) } } // TestUserService_Create_BootstrapPromotesFirstUser asserts the bootstrap // rule: when paliad.users is empty, the first inserter is silently promoted // to global_admin. Subsequent inserters default to standard. func TestUserService_Create_BootstrapPromotesFirstUser(t *testing.T) { users, pool, done := setupUserTest(t) defer done() ctx := context.Background() 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) u1, err := users.Create(ctx, first, "first@hlc.com", CreateUserInput{ DisplayName: "First", Office: "munich", JobTitle: "Partner", }) if err != nil { t.Fatalf("bootstrap user: %v", err) } if u1.GlobalRole != "global_admin" { t.Fatalf("first user should be global_admin, got %q", u1.GlobalRole) } u2, err := users.Create(ctx, second, "second@hlc.com", CreateUserInput{ DisplayName: "Second", Office: "munich", JobTitle: "Associate", }) if err != nil { t.Fatalf("second user: %v", err) } if u2.GlobalRole != "standard" { t.Fatalf("second user should be standard, got %q", u2.GlobalRole) } } // TestUserService_AdminUpdateUser_LastGlobalAdmin asserts the demotion guard. func TestUserService_AdminUpdateUser_LastGlobalAdmin(t *testing.T) { users, pool, done := setupUserTest(t) defer done() ctx := context.Background() pool.ExecContext(ctx, `DELETE FROM paliad.users`) first := uuid.MustParse("aaaaaaaa-0000-0000-0000-000000000021") seedAuthUser(t, pool, first, "lonely@hlc.com") defer cleanupUsers(t, pool, first) if _, err := users.Create(ctx, first, "lonely@hlc.com", CreateUserInput{ DisplayName: "Lonely", Office: "munich", JobTitle: "Counsel", }); err != nil { t.Fatalf("bootstrap: %v", err) } standard := "standard" _, err := users.AdminUpdateUser(ctx, first, AdminUpdateInput{GlobalRole: &standard}) if !errors.Is(err, ErrLastGlobalAdmin) { t.Fatalf("expected ErrLastGlobalAdmin, got %v", err) } } // TestUserService_IsAdmin asserts IsAdmin reads the global_role column. func TestUserService_IsAdmin(t *testing.T) { users, pool, done := setupUserTest(t) defer done() ctx := context.Background() pool.ExecContext(ctx, `DELETE FROM paliad.users`) id := uuid.MustParse("aaaaaaaa-0000-0000-0000-000000000031") seedAuthUser(t, pool, id, "first@hlc.com") defer cleanupUsers(t, pool, id) if _, err := users.Create(ctx, id, "first@hlc.com", CreateUserInput{ DisplayName: "First", Office: "munich", JobTitle: "Counsel", }); err != nil { t.Fatalf("create: %v", err) } got, err := users.IsAdmin(ctx, id) if err != nil { t.Fatalf("IsAdmin: %v", err) } if !got { t.Fatalf("bootstrap user should pass IsAdmin") } }