You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

207 lines
5.8 KiB
Go

package user
import (
"context"
"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
}
// newFakeRepo creates a fake repository with empty stores.
func newFakeRepo() *fakeRepo {
return &fakeRepo{
usersByID: map[string]*domain.User{},
usersByEmail: map[string]*domain.User{},
usersByZitadel: map[string]*domain.User{},
}
}
// EnsureSchema is a no-op for the in-memory fake repository.
func (r *fakeRepo) EnsureSchema(ctx context.Context) error { return nil }
// Create stores a user and assigns deterministic timestamps and ID for tests.
func (r *fakeRepo) Create(ctx context.Context, user *domain.User) (*domain.User, error) {
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 returns a non-deleted user by ID.
func (r *fakeRepo) GetByID(ctx context.Context, id string) (*domain.User, error) {
if u, ok := r.usersByID[id]; ok && !u.IsDeleted() {
return u, nil
}
return nil, sharederrors.Wrap(sharederrors.CodeUserNotFound, "user not found", nil)
}
// GetByEmail returns a non-deleted user by email.
func (r *fakeRepo) GetByEmail(ctx context.Context, email string) (*domain.User, error) {
if u, ok := r.usersByEmail[email]; ok && !u.IsDeleted() {
return u, nil
}
return nil, sharederrors.Wrap(sharederrors.CodeUserNotFound, "user not found", nil)
}
// GetByZitadelUserID returns a non-deleted user by Zitadel subject.
func (r *fakeRepo) GetByZitadelUserID(ctx context.Context, zid string) (*domain.User, error) {
if u, ok := r.usersByZitadel[zid]; ok && !u.IsDeleted() {
return u, nil
}
return nil, sharederrors.Wrap(sharederrors.CodeUserNotFound, "user not found", nil)
}
// UpdateProfile updates mutable user profile fields in place.
func (r *fakeRepo) UpdateProfile(
ctx context.Context,
id, displayName string,
consent domain.ConsentRecord,
) (*domain.User, error) {
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 marks a user as verified in the fake store.
func (r *fakeRepo) MarkEmailVerified(ctx context.Context, id string) (*domain.User, error) {
u, err := r.GetByID(ctx, id)
if err != nil {
return nil, err
}
u.EmailVerified = true
u.UpdatedAt = time.Now().UTC()
return u, nil
}
// SoftDelete marks a user as deleted and records an audit entry.
func (r *fakeRepo) SoftDelete(ctx context.Context, id string, actorUserID string) error {
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 users while honoring include-deleted filtering.
func (r *fakeRepo) List(
ctx context.Context,
pagination sharedtypes.Pagination,
filter domain.ListFilter,
) ([]*domain.User, int64, error) {
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 returns audit entries associated with a target user.
func (r *fakeRepo) AuditLogsByUserID(ctx context.Context, id string) ([]domain.AuditLogEntry, error) {
out := make([]domain.AuditLogEntry, 0)
for _, a := range r.audit {
if a.TargetUserID == id {
out = append(out, a)
}
}
return out, nil
}
// WriteAuditLog appends an audit entry to the fake repository.
func (r *fakeRepo) WriteAuditLog(ctx context.Context, entry domain.AuditLogEntry) error {
r.audit = append(r.audit, entry)
return nil
}
// TestRegisterIdempotent verifies repeated registration returns the same existing user.
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 verifies a deleted user can no longer be fetched.
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")
}
}