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") } }