Files
paliad/internal/services/user_service_test.go
m 460736ad1e refactor(t-paliad-092): rename Go module path patholo → paliad
F-6 from t-paliad-074 architecture audit. The Gitea repo was renamed
m/patholo → mAi/paliad → m/paliad, but go.mod still declared
`mgit.msbls.de/m/patholo` and every internal import echoed the
pre-rebrand name.

Sweep:
- go.mod: module path → mgit.msbls.de/m/paliad
- All *.go files: imports rewritten via sed
- README.md, docs/design-kanzlai-integration.md: mAi/paliad → m/paliad
- Frontend issue-reference comments (mAi/paliad#N → m/paliad#N) in
  i18n.ts, theme.ts, sidebar.ts, app.ts, Sidebar.tsx, PWAHead.tsx,
  global.css

Verified: go build/vet/test ./... clean, bun run build clean,
no remaining mgit.msbls.de/m/patholo or mAi/paliad references
outside docs that intentionally describe the rename history.
2026-04-30 16:46:31 +02:00

219 lines
6.6 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/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")
}
}