package user // service_test.go contains backend tests for package behavior, error paths, and regressions. import ( "context" "errors" "strings" "testing" "time" domain "knowfoolery/backend/services/user-service/internal/domain/user" sharederrors "knowfoolery/backend/shared/domain/errors" sharedtypes "knowfoolery/backend/shared/domain/types" ) // fakeRepo is an in-memory repository stub used by service unit tests. type fakeRepo struct { usersByID map[string]*domain.User usersByEmail map[string]*domain.User usersByZitadel map[string]*domain.User audit []domain.AuditLogEntry createErr error getByIDErr error getByEmailErr error getByZidErr error updateErr error verifyErr error deleteErr error listErr error logsErr error writeAuditErr error } // newFakeRepo creates in-memory test doubles and deterministic fixtures. func newFakeRepo() *fakeRepo { return &fakeRepo{ usersByID: map[string]*domain.User{}, usersByEmail: map[string]*domain.User{}, usersByZitadel: map[string]*domain.User{}, } } // EnsureSchema initializes schema state required before repository operations. func (r *fakeRepo) EnsureSchema(ctx context.Context) error { return nil } // Create persists a new entity in the in-memory repository. func (r *fakeRepo) Create(ctx context.Context, user *domain.User) (*domain.User, error) { if r.createErr != nil { return nil, r.createErr } user.ID = "u-1" now := time.Now().UTC() user.CreatedAt = now user.UpdatedAt = now r.usersByID[user.ID] = user r.usersByEmail[user.Email] = user r.usersByZitadel[user.ZitadelUserID] = user return user, nil } // GetByID retrieves data from the in-memory repository. func (r *fakeRepo) GetByID(ctx context.Context, id string) (*domain.User, error) { if r.getByIDErr != nil { return nil, r.getByIDErr } if u, ok := r.usersByID[id]; ok && !u.IsDeleted() { return u, nil } return nil, sharederrors.Wrap(sharederrors.CodeUserNotFound, "user not found", nil) } // GetByEmail retrieves data from the in-memory repository. func (r *fakeRepo) GetByEmail(ctx context.Context, email string) (*domain.User, error) { if r.getByEmailErr != nil { return nil, r.getByEmailErr } if u, ok := r.usersByEmail[email]; ok && !u.IsDeleted() { return u, nil } return nil, sharederrors.Wrap(sharederrors.CodeUserNotFound, "user not found", nil) } // GetByZitadelUserID retrieves data from the in-memory repository. func (r *fakeRepo) GetByZitadelUserID(ctx context.Context, zid string) (*domain.User, error) { if r.getByZidErr != nil { return nil, r.getByZidErr } if u, ok := r.usersByZitadel[zid]; ok && !u.IsDeleted() { return u, nil } return nil, sharederrors.Wrap(sharederrors.CodeUserNotFound, "user not found", nil) } // UpdateProfile updates an existing entity in the in-memory repository. func (r *fakeRepo) UpdateProfile( ctx context.Context, id, displayName string, consent domain.ConsentRecord, ) (*domain.User, error) { if r.updateErr != nil { return nil, r.updateErr } u, err := r.GetByID(ctx, id) if err != nil { return nil, err } u.DisplayName = displayName u.ConsentVersion = consent.Version u.ConsentGivenAt = consent.GivenAt u.ConsentSource = consent.Source u.UpdatedAt = time.Now().UTC() return u, nil } // MarkEmailVerified supports mark email verified test setup and assertions. func (r *fakeRepo) MarkEmailVerified(ctx context.Context, id string) (*domain.User, error) { if r.verifyErr != nil { return nil, r.verifyErr } u, err := r.GetByID(ctx, id) if err != nil { return nil, err } u.EmailVerified = true u.UpdatedAt = time.Now().UTC() return u, nil } // SoftDelete supports soft delete test setup and assertions. func (r *fakeRepo) SoftDelete(ctx context.Context, id string, actorUserID string) error { if r.deleteErr != nil { return r.deleteErr } u, err := r.GetByID(ctx, id) if err != nil { return err } now := time.Now().UTC() u.DeletedAt = &now r.audit = append( r.audit, domain.AuditLogEntry{ Action: domain.AuditActionGDPRDelete, TargetUserID: id, ActorUserID: actorUserID, }, ) return nil } // List returns filtered collections from the in-memory repository. func (r *fakeRepo) List( ctx context.Context, pagination sharedtypes.Pagination, filter domain.ListFilter, ) ([]*domain.User, int64, error) { if r.listErr != nil { return nil, 0, r.listErr } items := make([]*domain.User, 0, len(r.usersByID)) for _, u := range r.usersByID { if !filter.IncludeDeleted && u.IsDeleted() { continue } items = append(items, u) } return items, int64(len(items)), nil } // AuditLogsByUserID supports audit logs by user id test setup and assertions. func (r *fakeRepo) AuditLogsByUserID(ctx context.Context, id string) ([]domain.AuditLogEntry, error) { if r.logsErr != nil { return nil, r.logsErr } out := make([]domain.AuditLogEntry, 0) for _, a := range r.audit { if a.TargetUserID == id { out = append(out, a) } } return out, nil } // WriteAuditLog supports write audit log test setup and assertions. func (r *fakeRepo) WriteAuditLog(ctx context.Context, entry domain.AuditLogEntry) error { if r.writeAuditErr != nil { return r.writeAuditErr } r.audit = append(r.audit, entry) return nil } // TestRegisterIdempotent ensures register idempotent behavior is handled correctly. func TestRegisterIdempotent(t *testing.T) { repo := newFakeRepo() svc := NewService(repo) first, err := svc.Register(context.Background(), RegisterInput{ ActorZitadelUserID: "zitadel-1", ActorEmail: "player@example.com", DisplayName: "Player One", ConsentVersion: "v1", ConsentSource: "web", }) if err != nil { t.Fatalf("first register failed: %v", err) } second, err := svc.Register(context.Background(), RegisterInput{ ActorZitadelUserID: "zitadel-1", ActorEmail: "player@example.com", DisplayName: "Player One", ConsentVersion: "v1", ConsentSource: "web", }) if err != nil { t.Fatalf("second register failed: %v", err) } if first.ID != second.ID { t.Fatalf("expected idempotent register, got %s and %s", first.ID, second.ID) } } // TestDeleteAndExport ensures delete and export behavior is handled correctly. func TestDeleteAndExport(t *testing.T) { repo := newFakeRepo() svc := NewService(repo) u, err := svc.Register(context.Background(), RegisterInput{ ActorZitadelUserID: "zitadel-1", ActorEmail: "player@example.com", DisplayName: "Player One", ConsentVersion: "v1", ConsentSource: "web", }) if err != nil { t.Fatalf("register failed: %v", err) } if err := svc.DeleteUser(context.Background(), u.ID, "admin-1"); err != nil { t.Fatalf("delete failed: %v", err) } _, err = svc.GetProfile(context.Background(), u.ID) if err == nil { t.Fatal("expected deleted user to be unavailable") } } // TestRegisterValidationAndRepoErrors ensures register validation and repo errors behavior is handled correctly. func TestRegisterValidationAndRepoErrors(t *testing.T) { svc := NewService(newFakeRepo()) _, err := svc.Register(context.Background(), RegisterInput{}) if !errors.Is(err, domain.ErrUnauthorized) { t.Fatalf("expected unauthorized, got %v", err) } _, err = svc.Register(context.Background(), RegisterInput{ ActorZitadelUserID: "zid-1", ActorEmail: "bad-email", DisplayName: "Player", ConsentVersion: "v1", ConsentSource: "web", }) if !errors.Is(err, domain.ErrValidationFailed) { t.Fatalf("expected validation for bad email, got %v", err) } _, err = svc.Register(context.Background(), RegisterInput{ ActorZitadelUserID: "zid-1", ActorEmail: "a@b.com", DisplayName: "", ConsentVersion: "v1", ConsentSource: "web", }) if !errors.Is(err, domain.ErrValidationFailed) { t.Fatalf("expected validation for display name, got %v", err) } repo := newFakeRepo() repo.getByZidErr = errors.New("zid boom") svc = NewService(repo) _, err = svc.Register(context.Background(), RegisterInput{ ActorZitadelUserID: "zid-1", ActorEmail: "p@example.com", DisplayName: "Player", ConsentVersion: "v1", ConsentSource: "web", }) if err == nil || !strings.Contains(err.Error(), "zid boom") { t.Fatalf("expected zid lookup error, got %v", err) } repo = newFakeRepo() repo.createErr = errors.New("create boom") svc = NewService(repo) _, err = svc.Register(context.Background(), RegisterInput{ ActorZitadelUserID: "zid-1", ActorEmail: "p@example.com", DisplayName: "Player", ConsentVersion: "v1", ConsentSource: "web", }) if err == nil || !strings.Contains(err.Error(), "create boom") { t.Fatalf("expected create error, got %v", err) } } // TestProfileAndEmailFlows ensures profile and email flows behavior is handled correctly. func TestProfileAndEmailFlows(t *testing.T) { repo := newFakeRepo() svc := NewService(repo) user, err := svc.Register(context.Background(), RegisterInput{ ActorZitadelUserID: "zid-1", ActorEmail: "player@example.com", DisplayName: "Player One", ConsentVersion: "v1", ConsentSource: "web", }) if err != nil { t.Fatalf("register failed: %v", err) } _, err = svc.GetProfile(context.Background(), "") if !errors.Is(err, domain.ErrValidationFailed) { t.Fatalf("expected validation on empty id, got %v", err) } updated, err := svc.UpdateProfile(context.Background(), user.ID, UpdateProfileInput{ DisplayName: "Player Renamed", ConsentVersion: "v2", ConsentSource: "", }) if err != nil { t.Fatalf("update profile failed: %v", err) } if updated.DisplayName != "Player Renamed" { t.Fatalf("unexpected updated name: %s", updated.DisplayName) } verified, err := svc.VerifyEmail(context.Background(), user.ID) if err != nil { t.Fatalf("verify email failed: %v", err) } if !verified.EmailVerified { t.Fatalf("expected email verified") } } // TestProfileValidationAndRepoErrors ensures profile validation and repo errors behavior is handled correctly. func TestProfileValidationAndRepoErrors(t *testing.T) { repo := newFakeRepo() svc := NewService(repo) user, _ := svc.Register(context.Background(), RegisterInput{ ActorZitadelUserID: "zid-1", ActorEmail: "player@example.com", DisplayName: "Player One", ConsentVersion: "v1", ConsentSource: "web", }) _, err := svc.UpdateProfile(context.Background(), "", UpdateProfileInput{DisplayName: "x", ConsentVersion: "v1"}) if !errors.Is(err, domain.ErrValidationFailed) { t.Fatalf("expected validation on empty user id, got %v", err) } _, err = svc.UpdateProfile(context.Background(), user.ID, UpdateProfileInput{DisplayName: "", ConsentVersion: "v1"}) if !errors.Is(err, domain.ErrValidationFailed) { t.Fatalf("expected validation on display name, got %v", err) } _, err = svc.UpdateProfile(context.Background(), user.ID, UpdateProfileInput{DisplayName: "ok", ConsentVersion: strings.Repeat("a", 40)}) if !errors.Is(err, domain.ErrValidationFailed) { t.Fatalf("expected validation on consent version, got %v", err) } _, err = svc.UpdateProfile(context.Background(), user.ID, UpdateProfileInput{ DisplayName: "ok", ConsentVersion: "v1", ConsentSource: strings.Repeat("a", 40), }) if !errors.Is(err, domain.ErrValidationFailed) { t.Fatalf("expected validation on consent source, got %v", err) } repo.updateErr = errors.New("update boom") _, err = svc.UpdateProfile(context.Background(), user.ID, UpdateProfileInput{ DisplayName: "ok", ConsentVersion: "v1", ConsentSource: "web", }) if err == nil || !strings.Contains(err.Error(), "update boom") { t.Fatalf("expected update repo error, got %v", err) } repo.verifyErr = errors.New("verify boom") _, err = svc.VerifyEmail(context.Background(), user.ID) if err == nil || !strings.Contains(err.Error(), "verify boom") { t.Fatalf("expected verify repo error, got %v", err) } } // TestAdminListAndExport ensures admin list and export behavior is handled correctly. func TestAdminListAndExport(t *testing.T) { repo := newFakeRepo() svc := NewService(repo) user, _ := svc.Register(context.Background(), RegisterInput{ ActorZitadelUserID: "zid-1", ActorEmail: "player@example.com", DisplayName: "Player One", ConsentVersion: "v1", ConsentSource: "web", }) repo.audit = append(repo.audit, domain.AuditLogEntry{TargetUserID: user.ID, Action: domain.AuditActionGDPRExport}) items, total, pagination, err := svc.AdminListUsers(context.Background(), ListUsersInput{ Page: 0, PageSize: 0, Email: "PLAYER@EXAMPLE.COM", DisplayName: " Player ", }) if err != nil { t.Fatalf("admin list failed: %v", err) } if total == 0 || len(items) == 0 { t.Fatalf("expected listed users") } if pagination.Page <= 0 || pagination.PageSize <= 0 { t.Fatalf("expected normalized pagination, got %+v", pagination) } bundle, err := svc.AdminExportUser(context.Background(), user.ID, "admin-1") if err != nil { t.Fatalf("admin export failed: %v", err) } if bundle.User.ID != user.ID { t.Fatalf("unexpected export user id: %s", bundle.User.ID) } if len(repo.audit) == 0 { t.Fatalf("expected audit writes during export") } } // TestDeleteListExportErrors ensures delete list export errors behavior is handled correctly. func TestDeleteListExportErrors(t *testing.T) { repo := newFakeRepo() svc := NewService(repo) err := svc.DeleteUser(context.Background(), "", "admin") if !errors.Is(err, domain.ErrValidationFailed) { t.Fatalf("expected delete validation error, got %v", err) } repo.deleteErr = errors.New("delete boom") err = svc.DeleteUser(context.Background(), "u-1", "admin") if err == nil || !strings.Contains(err.Error(), "delete boom") { t.Fatalf("expected delete repo error, got %v", err) } repo.listErr = errors.New("list boom") _, _, _, err = svc.AdminListUsers(context.Background(), ListUsersInput{Page: 1, PageSize: 10}) if err == nil || !strings.Contains(err.Error(), "list boom") { t.Fatalf("expected list repo error, got %v", err) } _, err = svc.AdminExportUser(context.Background(), "", "admin") if !errors.Is(err, domain.ErrValidationFailed) { t.Fatalf("expected export validation error, got %v", err) } repo = newFakeRepo() svc = NewService(repo) repo.getByIDErr = errors.New("profile boom") _, err = svc.AdminExportUser(context.Background(), "u-1", "admin") if err == nil || !strings.Contains(err.Error(), "profile boom") { t.Fatalf("expected get profile error, got %v", err) } repo = newFakeRepo() svc = NewService(repo) user, _ := svc.Register(context.Background(), RegisterInput{ ActorZitadelUserID: "zid-1", ActorEmail: "player@example.com", DisplayName: "Player One", ConsentVersion: "v1", ConsentSource: "web", }) repo.logsErr = errors.New("logs boom") _, err = svc.AdminExportUser(context.Background(), user.ID, "admin") if err == nil || !strings.Contains(err.Error(), "logs boom") { t.Fatalf("expected logs error, got %v", err) } }