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.
219 lines
6.6 KiB
Go
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")
|
|
}
|
|
}
|