From 48aae18736515512708475c0a1be447494ef41dd Mon Sep 17 00:00:00 2001 From: Jean Jacques Avril Date: Sat, 4 Jan 2025 20:28:02 +0000 Subject: [PATCH] tests: added mock data sources to go backend --- README.md | 26 +++- backend-go/go.mod | 1 + backend-go/go.sum | 2 + .../application/services/user_service_test.go | 23 +++ .../data/prisma_project_data_source.go | 3 +- .../data/prisma_project_task_data_source.go | 3 +- .../data/prisma_time_entries_data_source.go | 3 +- .../data/prisma_user_data_source.go | 3 +- backend-go/internal/tests/mocks/helper.go | 10 ++ .../tests/mocks/mock_project_data_source.go | 125 +++++++++++++++ .../mocks/mock_project_task_data_source.go | 120 +++++++++++++++ .../mocks/mock_time_entry_data_source.go | 144 ++++++++++++++++++ .../tests/mocks/mock_user_data_source.go | 113 ++++++++++++++ 13 files changed, 567 insertions(+), 9 deletions(-) create mode 100644 backend-go/internal/application/services/user_service_test.go create mode 100644 backend-go/internal/tests/mocks/helper.go create mode 100644 backend-go/internal/tests/mocks/mock_project_data_source.go create mode 100644 backend-go/internal/tests/mocks/mock_project_task_data_source.go create mode 100644 backend-go/internal/tests/mocks/mock_time_entry_data_source.go create mode 100644 backend-go/internal/tests/mocks/mock_user_data_source.go diff --git a/README.md b/README.md index 9e55b5c..e76b21c 100755 --- a/README.md +++ b/README.md @@ -95,6 +95,7 @@ All dependencies are bundled with the **devcontainer**. For manual setup, ensure code . ``` + --- ## 🖥️ Running ActaTempus @@ -123,7 +124,7 @@ dart run bin/backend_dart.dart # Starts on port 8080 --- -## 🗄️ Database Management +### 🗄️ Database Management ActaTempus uses **Prisma ORM** for database schema management and code generation across both backends. Before the backend can connect you need to start the PostgresSQL server. To make things easier you can launch @@ -135,7 +136,7 @@ docker compose up -### Deploy the Schema +#### Deploy the Schema You only need to apply the schema with one of the following commands, as both result in the same schema. In case if you want to change the corresponding server edit the ```.env```-file within the backend projects. @@ -151,7 +152,7 @@ cd backend-go go run github.com/steebchen/prisma-client-go db push # within backend-go ``` -### Prisma Studio (UI) +#### Prisma Studio (UI) Prisma Studio is WebUI that improves development with Databases as it allows looking right into the data as well as well as altering it. ```bash @@ -163,18 +164,33 @@ bunx prisma studio To generate ORM Code for the specifig backend run the following commands. This is usually necessary after changes are made to the projects ``schema.prisma``-file. -#### Dart +##### Dart ```bash cd backend-dart bunx prisma generate ``` -#### Go +##### Go ```bash cd backend-go go run github.com/steebchen/prisma-client-go generate ``` +--- +## ⚠️ Testing +##### Dart + +```bash +cd backend-dart +dart test +``` + +##### Go +```bash +cd backend-go +go test ./... -v +``` + --- ## 🔧 Known Issues diff --git a/backend-go/go.mod b/backend-go/go.mod index ef28b7b..c3c519d 100755 --- a/backend-go/go.mod +++ b/backend-go/go.mod @@ -23,6 +23,7 @@ require ( github.com/go-playground/validator/v10 v10.23.0 // indirect github.com/goccy/go-json v0.10.4 // indirect github.com/golang-jwt/jwt/v5 v5.2.1 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.2.9 // indirect github.com/leodido/go-urn v1.4.0 // indirect diff --git a/backend-go/go.sum b/backend-go/go.sum index cb97750..89410f8 100755 --- a/backend-go/go.sum +++ b/backend-go/go.sum @@ -38,6 +38,8 @@ github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVI github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= diff --git a/backend-go/internal/application/services/user_service_test.go b/backend-go/internal/application/services/user_service_test.go new file mode 100644 index 0000000..ccf7622 --- /dev/null +++ b/backend-go/internal/application/services/user_service_test.go @@ -0,0 +1,23 @@ +package services_test + +import "testing" + + +func TestCreateUserService(t *testing.T) { + mockRepo := mocks.NewMockUserRepository() + service := services.NewUserService(mockRepo) + + user := entities.UserCreate{ + Email: "service@test.com", + Name: "Jane Doe", + Password: "securepassword", + } + + result := service.CreateUser(context.Background(), user) + + assert.True(t, result.IsRight(), "Expected service to create user") + result.Map(func(user entities.User) { + assert.Equal(t, "service@test.com", user.Email) + assert.Equal(t, "Jane Doe", user.Name) + }) +} \ No newline at end of file diff --git a/backend-go/internal/infrastructure/data/prisma_project_data_source.go b/backend-go/internal/infrastructure/data/prisma_project_data_source.go index 9f4e052..580b204 100644 --- a/backend-go/internal/infrastructure/data/prisma_project_data_source.go +++ b/backend-go/internal/infrastructure/data/prisma_project_data_source.go @@ -1,6 +1,7 @@ package data import ( + "actatempus_backend/internal/domain/data" "actatempus_backend/internal/domain/entities" "actatempus_backend/internal/infrastructure/data/db" "context" @@ -15,7 +16,7 @@ type PrismaProjectDataSource struct { client *db.PrismaClient } -func NewPrismaProjectDataSource(client *db.PrismaClient) *PrismaProjectDataSource { +func NewPrismaProjectDataSource(client *db.PrismaClient) data.ProjectDataSource { return &PrismaProjectDataSource{client: client} } diff --git a/backend-go/internal/infrastructure/data/prisma_project_task_data_source.go b/backend-go/internal/infrastructure/data/prisma_project_task_data_source.go index eba22ed..72f737f 100644 --- a/backend-go/internal/infrastructure/data/prisma_project_task_data_source.go +++ b/backend-go/internal/infrastructure/data/prisma_project_task_data_source.go @@ -1,6 +1,7 @@ package data import ( + "actatempus_backend/internal/domain/data" "actatempus_backend/internal/domain/entities" "actatempus_backend/internal/infrastructure/data/db" "context" @@ -15,7 +16,7 @@ type PrismaProjectTaskDataSource struct { client *db.PrismaClient } -func NewPrismaProjectTaskDataSource(client *db.PrismaClient) *PrismaProjectTaskDataSource { +func NewPrismaProjectTaskDataSource(client *db.PrismaClient) data.ProjectTaskDataSource { return &PrismaProjectTaskDataSource{client: client} } diff --git a/backend-go/internal/infrastructure/data/prisma_time_entries_data_source.go b/backend-go/internal/infrastructure/data/prisma_time_entries_data_source.go index 04dc44d..637dac7 100644 --- a/backend-go/internal/infrastructure/data/prisma_time_entries_data_source.go +++ b/backend-go/internal/infrastructure/data/prisma_time_entries_data_source.go @@ -1,6 +1,7 @@ package data import ( + "actatempus_backend/internal/domain/data" "actatempus_backend/internal/domain/entities" "actatempus_backend/internal/infrastructure/data/db" "context" @@ -15,7 +16,7 @@ type PrismaTimeEntryDataSource struct { client *db.PrismaClient } -func NewPrismaTimeEntryDataSource(client *db.PrismaClient) *PrismaTimeEntryDataSource { +func NewPrismaTimeEntryDataSource(client *db.PrismaClient) data.TimeEntryDataSource { return &PrismaTimeEntryDataSource{client: client} } diff --git a/backend-go/internal/infrastructure/data/prisma_user_data_source.go b/backend-go/internal/infrastructure/data/prisma_user_data_source.go index 6f0ee4f..41a442b 100644 --- a/backend-go/internal/infrastructure/data/prisma_user_data_source.go +++ b/backend-go/internal/infrastructure/data/prisma_user_data_source.go @@ -1,6 +1,7 @@ package data import ( + "actatempus_backend/internal/domain/data" "actatempus_backend/internal/domain/entities" "actatempus_backend/internal/infrastructure/data/db" "actatempus_backend/internal/utils" @@ -16,7 +17,7 @@ type PrismaUserDataSource struct { client *db.PrismaClient } -func NewPrismaUserDataSource(client *db.PrismaClient) *PrismaUserDataSource { +func NewPrismaUserDataSource(client *db.PrismaClient) data.UserDataSource { return &PrismaUserDataSource{client: client} } diff --git a/backend-go/internal/tests/mocks/helper.go b/backend-go/internal/tests/mocks/helper.go new file mode 100644 index 0000000..7d29054 --- /dev/null +++ b/backend-go/internal/tests/mocks/helper.go @@ -0,0 +1,10 @@ +package mocks + +import ( + "github.com/google/uuid" +) + +// GenerateID generates a new UUID string. +func GenerateID() string { + return uuid.New().String() +} diff --git a/backend-go/internal/tests/mocks/mock_project_data_source.go b/backend-go/internal/tests/mocks/mock_project_data_source.go new file mode 100644 index 0000000..a789b8d --- /dev/null +++ b/backend-go/internal/tests/mocks/mock_project_data_source.go @@ -0,0 +1,125 @@ +package mocks + +import ( + "actatempus_backend/internal/domain/data" + "actatempus_backend/internal/domain/entities" + impl "actatempus_backend/internal/infrastructure/data" + + "context" + "fmt" + "sync" + + E "github.com/IBM/fp-go/either" + F "github.com/IBM/fp-go/function" + O "github.com/IBM/fp-go/option" +) + +type MockProjectDataSource struct { + store map[string]*entities.Project + mu sync.RWMutex +} + +func NewMockProjectDataSource() data.ProjectDataSource { + return &MockProjectDataSource{ + store: make(map[string]*entities.Project), + } +} + +// Create adds a new project to the store +func (ds *MockProjectDataSource) Create(ctx context.Context, project entities.ProjectCreate) E.Either[error, entities.Project] { + ds.mu.Lock() + defer ds.mu.Unlock() + + id := GenerateID() + newProject := entities.Project{ + ID: id, + Name: project.Name, + UserID: project.UserID, + Description: project.Description, + ClientID: project.ClientID, + } + + ds.store[id] = &newProject + + return E.Right[error](newProject) +} + +// FindByID retrieves a project by its ID +func (ds *MockProjectDataSource) FindByID(ctx context.Context, id string) E.Either[error, entities.Project] { + ds.mu.RLock() + defer ds.mu.RUnlock() + + return F.Pipe2( + O.FromNillable(ds.store[id]), + E.FromOption[*entities.Project](F.Constant(fmt.Errorf("project with ID %s not found", id))), + E.Chain(impl.TryDereference[entities.Project]), + ) +} + +// Update modifies an existing project +func (ds *MockProjectDataSource) Update(ctx context.Context, project entities.ProjectUpdate) E.Either[error, entities.Project] { + ds.mu.Lock() + defer ds.mu.Unlock() + + existing, found := ds.store[project.ID] + if !found { + return E.Left[entities.Project](fmt.Errorf("project with ID %s not found", project.ID)) + } + + if project.Name != nil { + existing.Name = *project.Name + } + if project.Description != nil { + existing.Description = project.Description + } + if project.ClientID != nil { + existing.ClientID = project.ClientID + } + if project.UserID != nil { + existing.UserID = *project.UserID + } + + return E.Right[error](*existing) +} + +// Delete removes a project from the store +func (ds *MockProjectDataSource) Delete(ctx context.Context, id string) E.Either[error, entities.Project] { + ds.mu.Lock() + defer ds.mu.Unlock() + + existing, found := ds.store[id] + if !found { + return E.Left[entities.Project](fmt.Errorf("project with ID %s not found", id)) + } + + delete(ds.store, id) + return E.Right[error](*existing) +} + +// FindAll retrieves all projects +func (ds *MockProjectDataSource) FindAll(ctx context.Context) E.Either[error, []entities.Project] { + ds.mu.RLock() + defer ds.mu.RUnlock() + + projects := make([]entities.Project, 0, len(ds.store)) + for _, project := range ds.store { + projects = append(projects, *project) + } + + return E.Right[error](projects) +} + +// FindByUserID retrieves all projects for a specific user +func (ds *MockProjectDataSource) FindByUserID(ctx context.Context, userID string) E.Either[error, []entities.Project] { + ds.mu.RLock() + defer ds.mu.RUnlock() + + projects := make([]entities.Project, 0) + for _, project := range ds.store { + if project.UserID == userID { + projects = append(projects, *project) + } + } + + return E.Right[error](projects) +} diff --git a/backend-go/internal/tests/mocks/mock_project_task_data_source.go b/backend-go/internal/tests/mocks/mock_project_task_data_source.go new file mode 100644 index 0000000..5785811 --- /dev/null +++ b/backend-go/internal/tests/mocks/mock_project_task_data_source.go @@ -0,0 +1,120 @@ +package mocks + +import ( + "actatempus_backend/internal/domain/data" + "actatempus_backend/internal/domain/entities" + impl "actatempus_backend/internal/infrastructure/data" + "context" + "fmt" + "sync" + + E "github.com/IBM/fp-go/either" + F "github.com/IBM/fp-go/function" + O "github.com/IBM/fp-go/option" +) + +type MockProjectTaskDataSource struct { + store map[string]*entities.ProjectTask + mu sync.RWMutex +} + +func NewMockProjectTaskDataSource() data.ProjectTaskDataSource { + return &MockProjectTaskDataSource{ + store: make(map[string]*entities.ProjectTask), + } +} + +// Create a new ProjectTask +func (ds *MockProjectTaskDataSource) Create(ctx context.Context, task entities.ProjectTaskCreate) E.Either[error, entities.ProjectTask] { + ds.mu.Lock() + defer ds.mu.Unlock() + + id := GenerateID() + newTask := entities.ProjectTask{ + ID: id, + Name: task.Name, + ProjectID: task.ProjectID, + Description: task.Description, + } + + ds.store[id] = &newTask + + return E.Right[error](newTask) +} + +// Find ProjectTask by ID +func (ds *MockProjectTaskDataSource) FindByID(ctx context.Context, id string) E.Either[error, entities.ProjectTask] { + ds.mu.RLock() + defer ds.mu.RUnlock() + + return F.Pipe2( + O.FromNillable(ds.store[id]), + E.FromOption[*entities.ProjectTask](F.Constant(fmt.Errorf("project task with ID %s not found", id))), + E.Chain(impl.TryDereference[entities.ProjectTask]), + ) +} + +// Update an existing ProjectTask +func (ds *MockProjectTaskDataSource) Update(ctx context.Context, task entities.ProjectTaskUpdate) E.Either[error, entities.ProjectTask] { + ds.mu.Lock() + defer ds.mu.Unlock() + + existing, found := ds.store[task.ID] + if !found { + return E.Left[entities.ProjectTask](fmt.Errorf("project task with ID %s not found", task.ID)) + } + + if task.Name != nil { + existing.Name = *task.Name + } + if task.Description != nil { + existing.Description = task.Description + } + if task.ProjectID != nil { + existing.ProjectID = *task.ProjectID + } + + return E.Right[error](*existing) +} + +// Delete a ProjectTask +func (ds *MockProjectTaskDataSource) Delete(ctx context.Context, id string) E.Either[error, entities.ProjectTask] { + ds.mu.Lock() + defer ds.mu.Unlock() + + existing, found := ds.store[id] + if !found { + return E.Left[entities.ProjectTask](fmt.Errorf("project task with ID %s not found", id)) + } + + delete(ds.store, id) + return E.Right[error](*existing) +} + +// FindAll retrieves all ProjectTasks +func (ds *MockProjectTaskDataSource) FindAll(ctx context.Context) E.Either[error, []entities.ProjectTask] { + ds.mu.RLock() + defer ds.mu.RUnlock() + + tasks := make([]entities.ProjectTask, 0, len(ds.store)) + for _, task := range ds.store { + tasks = append(tasks, *task) + } + + return E.Right[error](tasks) +} + +// FindByProjectID retrieves all ProjectTasks for a given Project +func (ds *MockProjectTaskDataSource) FindByProjectID(ctx context.Context, projectID string) E.Either[error, []entities.ProjectTask] { + ds.mu.RLock() + defer ds.mu.RUnlock() + + tasks := make([]entities.ProjectTask, 0) + for _, task := range ds.store { + if task.ProjectID == projectID { + tasks = append(tasks, *task) + } + } + + return E.Right[error](tasks) +} diff --git a/backend-go/internal/tests/mocks/mock_time_entry_data_source.go b/backend-go/internal/tests/mocks/mock_time_entry_data_source.go new file mode 100644 index 0000000..43f0470 --- /dev/null +++ b/backend-go/internal/tests/mocks/mock_time_entry_data_source.go @@ -0,0 +1,144 @@ +package mocks + +import ( + impl "actatempus_backend/internal/infrastructure/data" + + "actatempus_backend/internal/domain/data" + "actatempus_backend/internal/domain/entities" + "context" + "fmt" + "sync" + + E "github.com/IBM/fp-go/either" + F "github.com/IBM/fp-go/function" + O "github.com/IBM/fp-go/option" +) + +type MockTimeEntryDataSource struct { + store map[string]*entities.TimeEntry + mu sync.RWMutex +} + +func NewMockTimeEntryDataSource() data.TimeEntryDataSource { + return &MockTimeEntryDataSource{ + store: make(map[string]*entities.TimeEntry), + } +} + +// Create a new TimeEntry +func (ds *MockTimeEntryDataSource) Create(ctx context.Context, entry entities.TimeEntryCreate) E.Either[error, entities.TimeEntry] { + ds.mu.Lock() + defer ds.mu.Unlock() + + id := GenerateID() + newEntry := entities.TimeEntry{ + ID: id, + StartTime: entry.StartTime, + EndTime: entry.EndTime, + Description: entry.Description, + UserID: entry.UserID, + ProjectID: entry.ProjectID, + } + + ds.store[id] = &newEntry + + return E.Right[error](newEntry) +} + +// Find TimeEntry by ID +func (ds *MockTimeEntryDataSource) FindByID(ctx context.Context, id string) E.Either[error, entities.TimeEntry] { + ds.mu.RLock() + defer ds.mu.RUnlock() + + return F.Pipe2( + O.FromNillable(ds.store[id]), + E.FromOption[*entities.TimeEntry](F.Constant(fmt.Errorf("time entry with ID %s not found", id))), + E.Chain(impl.TryDereference[entities.TimeEntry]), + ) +} + +// Update an existing TimeEntry +func (ds *MockTimeEntryDataSource) Update(ctx context.Context, entry entities.TimeEntryUpdate) E.Either[error, entities.TimeEntry] { + ds.mu.Lock() + defer ds.mu.Unlock() + + existing, found := ds.store[entry.ID] + if !found { + return E.Left[entities.TimeEntry](fmt.Errorf("time entry with ID %s not found", entry.ID)) + } + + if entry.StartTime != nil { + existing.StartTime = *entry.StartTime + } + if entry.EndTime != nil { + existing.EndTime = entry.EndTime + } + if entry.Description != nil { + existing.Description = entry.Description + } + if entry.UserID != nil { + existing.UserID = *entry.UserID + } + if entry.ProjectID != nil { + existing.ProjectID = *entry.ProjectID + } + + return E.Right[error](*existing) +} + +// Delete a TimeEntry +func (ds *MockTimeEntryDataSource) Delete(ctx context.Context, id string) E.Either[error, entities.TimeEntry] { + ds.mu.Lock() + defer ds.mu.Unlock() + + existing, found := ds.store[id] + if !found { + return E.Left[entities.TimeEntry](fmt.Errorf("time entry with ID %s not found", id)) + } + + delete(ds.store, id) + return E.Right[error](*existing) +} + +// FindAll retrieves all TimeEntries +func (ds *MockTimeEntryDataSource) FindAll(ctx context.Context) E.Either[error, []entities.TimeEntry] { + ds.mu.RLock() + defer ds.mu.RUnlock() + + entries := make([]entities.TimeEntry, 0, len(ds.store)) + for _, entry := range ds.store { + entries = append(entries, *entry) + } + + return E.Right[error](entries) +} + +// FindByUserID retrieves all TimeEntries by UserID +func (ds *MockTimeEntryDataSource) FindByUserID(ctx context.Context, userID string) E.Either[error, []entities.TimeEntry] { + ds.mu.RLock() + defer ds.mu.RUnlock() + + entries := make([]entities.TimeEntry, 0) + for _, entry := range ds.store { + if entry.UserID == userID { + entries = append(entries, *entry) + } + } + + return E.Right[error](entries) +} + +// FindByProjectID retrieves all TimeEntries by ProjectID +func (ds *MockTimeEntryDataSource) FindByProjectID(ctx context.Context, projectID string) E.Either[error, []entities.TimeEntry] { + ds.mu.RLock() + defer ds.mu.RUnlock() + + entries := make([]entities.TimeEntry, 0) + for _, entry := range ds.store { + if entry.ProjectID == projectID { + entries = append(entries, *entry) + } + } + + return E.Right[error](entries) +} diff --git a/backend-go/internal/tests/mocks/mock_user_data_source.go b/backend-go/internal/tests/mocks/mock_user_data_source.go new file mode 100644 index 0000000..45018e4 --- /dev/null +++ b/backend-go/internal/tests/mocks/mock_user_data_source.go @@ -0,0 +1,113 @@ +package mocks + +import ( + domain "actatempus_backend/internal/domain/data" + "actatempus_backend/internal/domain/entities" + impl "actatempus_backend/internal/infrastructure/data" + "context" + "fmt" + "sync" + + E "github.com/IBM/fp-go/either" + F "github.com/IBM/fp-go/function" + O "github.com/IBM/fp-go/option" +) + +type MockUserDataSource struct { + store map[string]*entities.User + mu sync.RWMutex +} + +func NewMockUserDataSource() domain.UserDataSource { + return &MockUserDataSource{ + store: make(map[string]*entities.User), + } +} + +func (ds *MockUserDataSource) Create(ctx context.Context, user entities.UserCreate) E.Either[error, entities.User] { + ds.mu.Lock() + defer ds.mu.Unlock() + + id := GenerateID() + newUser := entities.User{ + ID: id, + Name: user.Name, + Email: user.Email, + Password: impl.GenerateSecureHash(user.Password), + } + + ds.store[id] = &newUser + + return E.Right[error](newUser) +} + +func (ds *MockUserDataSource) FindByID(ctx context.Context, id string) E.Either[error, entities.User] { + ds.mu.RLock() + defer ds.mu.RUnlock() + return F.Pipe2( + O.FromNillable(ds.store[id]), + E.FromOption[*entities.User](F.Constant(fmt.Errorf("user with ID %s not found", id))), + E.Chain( + impl.TryDereference[entities.User], + ), + ) +} + +func (ds *MockUserDataSource) FindByEmail(ctx context.Context, email string) E.Either[error, entities.User] { + ds.mu.RLock() + defer ds.mu.RUnlock() + + for _, user := range ds.store { + if user.Email == email { + return E.Right[error](*user) + } + } + return E.Left[entities.User](fmt.Errorf("user with email %s not found", email)) +} + +func (ds *MockUserDataSource) Update(ctx context.Context, user entities.UserUpdate) E.Either[error, entities.User] { + ds.mu.Lock() + defer ds.mu.Unlock() + + existing, found := ds.store[user.ID] + if !found { + return E.Left[entities.User](fmt.Errorf("user with ID %s not found", user.ID)) + } + + if user.Name != nil { + existing.Name = *user.Name + } + if user.Email != nil { + existing.Email = *user.Email + } + if user.Password != nil { + existing.Password = impl.GenerateSecureHash(*user.Password) + } + + return E.Right[error](*existing) +} + +func (ds *MockUserDataSource) Delete(ctx context.Context, id string) E.Either[error, entities.User] { + ds.mu.Lock() + defer ds.mu.Unlock() + + existing, found := ds.store[id] + if !found { + return E.Left[entities.User](fmt.Errorf("user with ID %s not found", id)) + } + + delete(ds.store, id) + return E.Right[error](*existing) +} + +func (ds *MockUserDataSource) FindAll(ctx context.Context) E.Either[error, []entities.User] { + ds.mu.RLock() + defer ds.mu.RUnlock() + + users := make([]entities.User, 0, len(ds.store)) + for _, user := range ds.store { + users = append(users, *user) + } + + return E.Right[error](users) +}