diff --git a/server/src/cmd/service/main.go b/server/src/cmd/service/main.go index bf919cd..d2b2954 100644 --- a/server/src/cmd/service/main.go +++ b/server/src/cmd/service/main.go @@ -30,12 +30,16 @@ func main() { teamRepository := database.NewGormTeamRepository(databaseService.Database) teamUsecase := usecase.NewTeamUsecase(teamRepository) teamHandler := rest.NewTeamHandler(tokenVerifier, teamUsecase) - projectUsecase := usecase.NewProjectUsecase(database.NewGormProjectRepository(databaseService.Database, - teamRepository), teamUsecase) + + projectUsecase := usecase.NewProjectUsecase(database.NewGormProjectRepository(databaseService.Database, teamRepository), teamUsecase) projectHandler := rest.NewProjectHandler(tokenVerifier, projectUsecase, teamUsecase) + timeEntryUsecase := usecase.NewTimeEntryUsecase(database.NewGormTimeEntryRepository(databaseService.Database), projectUsecase) timeEntryHandler := rest.NewTimeEntryHandler(tokenVerifier, timeEntryUsecase) - router := rest.SetupRouter(authMiddleware, teamHandler, projectHandler, timeEntryHandler) + syncUsecase := usecase.NewSyncUsecase(database.NewGormSyncRepository(databaseService.Database)) + syncHandler := rest.NewSyncHandler(tokenVerifier, syncUsecase) + + router := rest.SetupRouter(authMiddleware, teamHandler, projectHandler, timeEntryHandler, syncHandler) router.Run() } diff --git a/server/src/pkg/database/gorm_sync_repository.go b/server/src/pkg/database/gorm_sync_repository.go new file mode 100644 index 0000000..9441056 --- /dev/null +++ b/server/src/pkg/database/gorm_sync_repository.go @@ -0,0 +1,78 @@ +package database + +import ( + "time" + "timeasy-server/pkg/domain/model" + "timeasy-server/pkg/domain/repository" + + "github.com/gofrs/uuid" + "gorm.io/gorm" +) + +type gormSyncRepository struct { + db *gorm.DB +} + +func NewGormSyncRepository(database *gorm.DB) repository.SyncRepository { + return &gormSyncRepository{ + db: database, + } +} + +func (repo *gormSyncRepository) UpdateAndDeleteData(data model.SyncData) error { + return repo.db.Transaction(func(tx *gorm.DB) error { + err := repo.updateAndDeleteProjects(tx, data) + if err != nil { + return err + } + err = repo.updateAndDeleteTimeEntries(tx, data) + if err != nil { + return err + } + return nil + }) +} + +func (repo *gormSyncRepository) updateAndDeleteProjects(tx *gorm.DB, data model.SyncData) error { + for _, project := range data.ProjectsToBeUpdated { + if err := tx.Save(&project).Error; err != nil { + return err + } + } + for _, project := range data.ProjectsToBeDeleted { + if err := tx.Delete(&project).Error; err != nil { + return err + } + } + return nil +} + +func (repo *gormSyncRepository) updateAndDeleteTimeEntries(tx *gorm.DB, data model.SyncData) error { + for _, timeEntry := range data.TimeEntriesToBeUpdated { + if err := tx.Save(&timeEntry).Error; err != nil { + return err + } + } + for _, timeEntry := range data.TimeEntriesToBeDeleted { + if err := tx.Delete(&timeEntry).Error; err != nil { + return err + } + } + return nil +} + +func (repo *gormSyncRepository) GetUpdatedTimeEntriesOfUser(userId uuid.UUID, sinceWhen time.Time) ([]model.TimeEntry, error) { + var updatedEntries []model.TimeEntry + if err := repo.db.Unscoped().Order("start_time desc").Order("end_time desc").Find(&updatedEntries, "user_id=? AND (updated_at >= ? OR created_at >= ? OR deleted_at >= ?)", userId, sinceWhen, sinceWhen, sinceWhen).Error; err != nil { + return nil, err + } + return updatedEntries, nil +} + +func (repo *gormSyncRepository) GetUpdatedProjectsOfUser(userId uuid.UUID, sinceWhen time.Time) ([]model.Project, error) { + var updatedProjects []model.Project + if err := repo.db.Unscoped().Order("name").Find(&updatedProjects, "user_id=? AND (updated_at >= ? OR created_at >= ? OR deleted_at >= ?)", userId, sinceWhen, sinceWhen, sinceWhen).Error; err != nil { + return nil, err + } + return updatedProjects, nil +} diff --git a/server/src/pkg/database/gorm_timeentry_repository.go b/server/src/pkg/database/gorm_timeentry_repository.go index ef7ea8a..9bcc118 100644 --- a/server/src/pkg/database/gorm_timeentry_repository.go +++ b/server/src/pkg/database/gorm_timeentry_repository.go @@ -25,6 +25,17 @@ func (repo *gormTimeEntryRepository) AddTimeEntry(timeEntry *model.TimeEntry) er return nil } +func (repo *gormTimeEntryRepository) AddTimeEntryList(timeEntryList []model.TimeEntry) error { + return repo.db.Transaction(func(tx *gorm.DB) error { + for _, timeEntry := range timeEntryList { + if err := tx.Create(&timeEntry).Error; err != nil { + return err + } + } + return nil + }) +} + func (repo *gormTimeEntryRepository) GetTimeEntryById(id uuid.UUID) (*model.TimeEntry, error) { var timeEntry model.TimeEntry if err := repo.db.First(&timeEntry, id).Error; err != nil { @@ -40,6 +51,17 @@ func (repo *gormTimeEntryRepository) UpdateTimeEntry(timeEntry *model.TimeEntry) return nil } +func (repo *gormTimeEntryRepository) UpdateTimeEntryList(timeEntryList []model.TimeEntry) error { + return repo.db.Transaction(func(tx *gorm.DB) error { + for _, timeEntry := range timeEntryList { + if err := tx.Save(&timeEntry).Error; err != nil { + return err + } + } + return nil + }) +} + func (repo *gormTimeEntryRepository) DeleteTimeEntry(timeEntry *model.TimeEntry) error { if err := repo.db.Delete(timeEntry).Error; err != nil { return err diff --git a/server/src/pkg/domain/model/sync_data.go b/server/src/pkg/domain/model/sync_data.go new file mode 100644 index 0000000..19a8f00 --- /dev/null +++ b/server/src/pkg/domain/model/sync_data.go @@ -0,0 +1,8 @@ +package model + +type SyncData struct { + TimeEntriesToBeUpdated []TimeEntry + TimeEntriesToBeDeleted []TimeEntry + ProjectsToBeUpdated []Project + ProjectsToBeDeleted []Project +} diff --git a/server/src/pkg/domain/repository/sync_repository.go b/server/src/pkg/domain/repository/sync_repository.go new file mode 100644 index 0000000..8ce3fdf --- /dev/null +++ b/server/src/pkg/domain/repository/sync_repository.go @@ -0,0 +1,14 @@ +package repository + +import ( + "time" + "timeasy-server/pkg/domain/model" + + "github.com/gofrs/uuid" +) + +type SyncRepository interface { + UpdateAndDeleteData(data model.SyncData) error + GetUpdatedTimeEntriesOfUser(userId uuid.UUID, sinceWhen time.Time) ([]model.TimeEntry, error) + GetUpdatedProjectsOfUser(userId uuid.UUID, sinceWhen time.Time) ([]model.Project, error) +} diff --git a/server/src/pkg/domain/repository/timeentry_repository.go b/server/src/pkg/domain/repository/timeentry_repository.go index dbc4722..b117c81 100644 --- a/server/src/pkg/domain/repository/timeentry_repository.go +++ b/server/src/pkg/domain/repository/timeentry_repository.go @@ -8,7 +8,9 @@ import ( type TimeEntryRepository interface { AddTimeEntry(project *model.TimeEntry) error - UpdateTimeEntry(project *model.TimeEntry) error + AddTimeEntryList(timeEntryList []model.TimeEntry) error + UpdateTimeEntry(timeEntry *model.TimeEntry) error + UpdateTimeEntryList(timeEntryList []model.TimeEntry) error DeleteTimeEntry(project *model.TimeEntry) error GetTimeEntryById(id uuid.UUID) (*model.TimeEntry, error) GetAllTimeEntriesOfUser(userId uuid.UUID) ([]model.TimeEntry, error) diff --git a/server/src/pkg/transport/rest/handler_test.go b/server/src/pkg/transport/rest/handler_test.go index 7be922e..c024c03 100644 --- a/server/src/pkg/transport/rest/handler_test.go +++ b/server/src/pkg/transport/rest/handler_test.go @@ -57,9 +57,11 @@ type HandlerTest struct { ProjectUsecase usecase.ProjectUsecase TimeEntryUsecase usecase.TimeEntryUsecase TeamUsecase usecase.TeamUsecase + SyncUsecase usecase.SyncUsecase ProjectHandler ProjectHandler TimeEntryHandler TimeEntryHandler TeamHandler TeamHandler + SyncHandler SyncHandler Router *gin.Engine tokenVerifier TokenVerifier } @@ -90,6 +92,9 @@ func (t *HandlerTest) initUsecases() { timeEntryRepo := database.NewGormTimeEntryRepository(test.DB) t.TimeEntryUsecase = usecase.NewTimeEntryUsecase(timeEntryRepo, t.ProjectUsecase) + + syncRepo := database.NewGormSyncRepository(test.DB) + t.SyncUsecase = usecase.NewSyncUsecase(syncRepo) } func (t *HandlerTest) initHandlers() { @@ -97,8 +102,9 @@ func (t *HandlerTest) initHandlers() { t.ProjectHandler = NewProjectHandler(t.tokenVerifier, t.ProjectUsecase, t.TeamUsecase) t.TimeEntryHandler = NewTimeEntryHandler(t.tokenVerifier, t.TimeEntryUsecase) t.TeamHandler = NewTeamHandler(t.tokenVerifier, t.TeamUsecase) + t.SyncHandler = NewSyncHandler(t.tokenVerifier, t.SyncUsecase) - t.Router = SetupRouter(authMiddleware, t.TeamHandler, t.ProjectHandler, t.TimeEntryHandler) + t.Router = SetupRouter(authMiddleware, t.TeamHandler, t.ProjectHandler, t.TimeEntryHandler, t.SyncHandler) } func AssertErrorMessageEquals(t *testing.T, responseBody []byte, expectedMessage string) { diff --git a/server/src/pkg/transport/rest/routing.go b/server/src/pkg/transport/rest/routing.go index b83ef7e..5e16183 100644 --- a/server/src/pkg/transport/rest/routing.go +++ b/server/src/pkg/transport/rest/routing.go @@ -7,7 +7,7 @@ import ( ginglog "github.com/szuecs/gin-glog" ) -func SetupRouter(authMiddleware AuthMiddleware, teamHandler TeamHandler, projectHandler ProjectHandler, timeEntryHandler TimeEntryHandler) *gin.Engine { +func SetupRouter(authMiddleware AuthMiddleware, teamHandler TeamHandler, projectHandler ProjectHandler, timeEntryHandler TimeEntryHandler, syncHandler SyncHandler) *gin.Engine { router := gin.Default() router.Use(ginglog.Logger(3 * time.Second)) @@ -34,6 +34,8 @@ func SetupRouter(authMiddleware AuthMiddleware, teamHandler TeamHandler, project protectedGroup.POST("/teams/:id/users", teamHandler.AddUserToTeam) protectedGroup.DELETE("/teams/:id/users/:userId", teamHandler.DeleteUserFromTeam) protectedGroup.PUT("/teams/:id/users/:userId/roles", teamHandler.UpdateUserRolesInTeam) + protectedGroup.GET("/sync/changed/:timestamp", syncHandler.GetChangedEntries) + protectedGroup.POST("/sync/changed", syncHandler.SendLocallyChangedEntries) return router } diff --git a/server/src/pkg/transport/rest/sync_dtos.go b/server/src/pkg/transport/rest/sync_dtos.go new file mode 100644 index 0000000..4e2b0e1 --- /dev/null +++ b/server/src/pkg/transport/rest/sync_dtos.go @@ -0,0 +1,81 @@ +package rest + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/gofrs/uuid" +) + +type ChangeType uint8 + +const ( + NEW ChangeType = iota + CHANGED + DELETED +) + +var ( + ChangeType_Name = map[uint8]string{ + 0: "NEW", + 1: "CHANGED", + 2: "DELETED", + } + + ChangeType_Value = map[string]uint8{ + "NEW": 0, + "CHANGED": 1, + "DELETED": 2, + } +) + +func (c ChangeType) String() string { + return ChangeType_Name[uint8(c)] +} + +func (c ChangeType) MarshalJSON() ([]byte, error) { + return json.Marshal(c.String()) +} + +func (c *ChangeType) UnmarshalJSON(data []byte) (err error) { + var sType string + if err := json.Unmarshal(data, &sType); err != nil { + return err + } + if *c, err = c.parse(sType); err != nil { + return err + } + return nil +} + +func (c *ChangeType) parse(sType string) (ChangeType, error) { + sType = strings.TrimSpace(strings.ToUpper(sType)) + value, ok := ChangeType_Value[sType] + if !ok { + return ChangeType(0), fmt.Errorf("%v is not a valid change type", sType) + } + return ChangeType(value), nil +} + +type SyncEntries struct { + TimeEntries []ChangedTimeEntryDto + Projects []ChangedProjectDto +} + +type ChangedTimeEntryDto struct { + Id uuid.UUID + Description string `json:"description" binding:"required"` + StartTimeUTCUnix int64 `json:"startTimeUTCUnix" binding:"required"` + EndTimeUTCUnix int64 + ProjectId uuid.UUID `json:"projectId" binding:"required"` + ChangeType ChangeType `json:"changeType" binding:"required"` + ChangeTimestampUTCUnix int64 `json:"changeTimestampUTCUnix" binding:"required"` +} + +type ChangedProjectDto struct { + Id uuid.UUID + Name string `json:"name" binding:"required"` + ChangeType ChangeType `json:"changeType" binding:"required"` + ChangeTimestampUTCUnix int64 `json:"changeTimestampUTCUnix" binding:"required"` +} diff --git a/server/src/pkg/transport/rest/sync_handler.go b/server/src/pkg/transport/rest/sync_handler.go new file mode 100644 index 0000000..667c65f --- /dev/null +++ b/server/src/pkg/transport/rest/sync_handler.go @@ -0,0 +1,146 @@ +package rest + +import ( + "net/http" + "strconv" + "time" + "timeasy-server/pkg/domain/model" + "timeasy-server/pkg/usecase" + + "github.com/gin-gonic/gin" + "github.com/gofrs/uuid" +) + +type SyncHandler interface { + GetChangedEntries(context *gin.Context) + SendLocallyChangedEntries(context *gin.Context) +} + +type syncHandler struct { + tokenVerifier TokenVerifier + syncUsecase usecase.SyncUsecase +} + +func NewSyncHandler(tokenVerifier TokenVerifier, syncUsecase usecase.SyncUsecase) SyncHandler { + return &syncHandler{ + tokenVerifier: tokenVerifier, + syncUsecase: syncUsecase, + } +} + +func (handler *syncHandler) GetChangedEntries(context *gin.Context) { + token, err := handler.tokenVerifier.VerifyToken(context) + if err != nil { + context.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + return + } + userId, err := token.GetUserId() + if err != nil { + context.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + timeParam := context.Param("timestamp") + unixTime, err := strconv.ParseInt(timeParam, 10, 64) + if err != nil { + context.JSON(http.StatusBadRequest, gin.H{"error": "please provide a valid unix timestamp"}) + return + } + + var syncEntries SyncEntries + entries, err := handler.syncUsecase.GetChangedTimeEntries(userId, time.Unix(unixTime, 0)) + for _, entry := range entries { + changeType := CHANGED + changeTime := entry.UpdatedAt + if !entry.DeletedAt.Time.IsZero() { + changeType = DELETED + changeTime = entry.DeletedAt.Time + } else if entry.CreatedAt == entry.UpdatedAt { + changeType = NEW + changeTime = entry.CreatedAt + } + syncTimeEntry := ChangedTimeEntryDto{ + Id: entry.ID, + Description: entry.Description, + StartTimeUTCUnix: entry.StartTime.Unix(), + EndTimeUTCUnix: entry.EndTime.Unix(), + ProjectId: entry.ProjectId, + ChangeType: changeType, + ChangeTimestampUTCUnix: changeTime.Unix(), + } + syncEntries.TimeEntries = append(syncEntries.TimeEntries, syncTimeEntry) + } + + projects, err := handler.syncUsecase.GetChangedProjects(userId, time.Unix(unixTime, 0)) + for _, project := range projects { + changeType := CHANGED + changeTime := project.UpdatedAt + if !project.DeletedAt.Time.IsZero() { + changeType = DELETED + changeTime = project.DeletedAt.Time + } else if project.CreatedAt == project.UpdatedAt { + changeType = NEW + changeTime = project.CreatedAt + } + syncProject := ChangedProjectDto{ + Id: project.ID, + Name: project.Name, + ChangeType: changeType, + ChangeTimestampUTCUnix: changeTime.Unix(), + } + syncEntries.Projects = append(syncEntries.Projects, syncProject) + } + + context.JSON(http.StatusOK, syncEntries) +} + +func (handler *syncHandler) SendLocallyChangedEntries(context *gin.Context) { + var syncDtos SyncEntries + if err := context.ShouldBindJSON(&syncDtos); err != nil { + context.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + token, err := handler.tokenVerifier.VerifyToken(context) + if err != nil { + context.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + return + } + userId, err := token.GetUserId() + if err != nil { + context.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + var syncData model.SyncData + handler.fillInClientSideChangedTimeEntries(&syncData, syncDtos.TimeEntries, userId) + + err = handler.syncUsecase.UpdateAndDeleteData(syncData) + if err != nil { + context.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + context.JSON(http.StatusOK, nil) +} + +func (handler *syncHandler) fillInClientSideChangedTimeEntries(syncData *model.SyncData, changedTimeEntries []ChangedTimeEntryDto, userId uuid.UUID) { + for _, changedTimeEntry := range changedTimeEntries { + timeEntry := handler.createTimeEntryFromDto(changedTimeEntry, userId) + switch changedTimeEntry.ChangeType { + case NEW, CHANGED: + syncData.TimeEntriesToBeUpdated = append(syncData.TimeEntriesToBeUpdated, timeEntry) + case DELETED: + syncData.TimeEntriesToBeDeleted = append(syncData.TimeEntriesToBeDeleted, timeEntry) + } + } +} + +func (handler *syncHandler) createTimeEntryFromDto(timeEntryDto ChangedTimeEntryDto, userId uuid.UUID) model.TimeEntry { + timeEntry := model.TimeEntry{ + ID: timeEntryDto.Id, + ProjectId: timeEntryDto.ProjectId, + UserId: userId, + Description: timeEntryDto.Description, + StartTime: time.Unix(timeEntryDto.StartTimeUTCUnix, 0).UTC(), + EndTime: time.Unix(timeEntryDto.EndTimeUTCUnix, 0).UTC(), + } + return timeEntry +} diff --git a/server/src/pkg/transport/rest/sync_handler_test.go b/server/src/pkg/transport/rest/sync_handler_test.go new file mode 100644 index 0000000..3dce706 --- /dev/null +++ b/server/src/pkg/transport/rest/sync_handler_test.go @@ -0,0 +1,361 @@ +package rest + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + "timeasy-server/pkg/domain/model" + + "github.com/gofrs/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func Test_syncHandler_GetChangedTimeEntries(t *testing.T) { + userId, err := uuid.NewV4() + assert.Nil(t, err) + token := authTokenMock{} + token.On("GetUserId").Return(userId, nil) + token.On("HasRole", model.RoleUser).Return(true, nil) + token.On("HasRole", model.RoleAdmin).Return(false, nil) + + verifier := tokenVerifierMock{} + verifier.On("VerifyToken", mock.Anything).Return(&token, nil) + + handlerTest := NewHandlerTest(&verifier) + teardownTest := handlerTest.SetupTest(t) + defer teardownTest(t) + + project := model.Project{ + Name: "project", + UserId: userId, + } + err = handlerTest.ProjectUsecase.AddProject(&project) + assert.Nil(t, err) + + startTime := time.Date(2023, 1, 28, 11, 0, 0, 0, time.UTC) + + unchangedTimeEntry := model.TimeEntry{ + Description: "unchanged_timeentry", + StartTime: startTime, + ProjectId: project.ID, + UserId: userId, + } + unchangedTimeEntry.UpdatedAt = time.Date(2023, 8, 1, 0, 0, 0, 0, time.UTC) + unchangedTimeEntry.CreatedAt = time.Date(2023, 8, 1, 0, 0, 0, 0, time.UTC) + err = handlerTest.TimeEntryUsecase.AddTimeEntry(&unchangedTimeEntry) + assert.Nil(t, err) + + updatedTimeEntry := model.TimeEntry{ + Description: "original_timeentry", + StartTime: startTime.Add(time.Hour), + ProjectId: project.ID, + UserId: userId, + } + updatedTimeEntry.UpdatedAt = time.Date(2023, 8, 1, 0, 0, 0, 0, time.UTC) + updatedTimeEntry.CreatedAt = time.Date(2023, 8, 1, 0, 0, 0, 0, time.UTC) + err = handlerTest.TimeEntryUsecase.AddTimeEntry(&updatedTimeEntry) + assert.Nil(t, err) + updatedTimeEntry.Description = "updated_timeetry" + err = handlerTest.TimeEntryUsecase.UpdateTimeEntry(&updatedTimeEntry) + assert.Nil(t, err) + + deletedTimeEntry := model.TimeEntry{ + Description: "deleted_timeentry", + StartTime: startTime.Add(time.Hour).Add(time.Hour), + ProjectId: project.ID, + UserId: userId, + } + deletedTimeEntry.UpdatedAt = time.Date(2023, 8, 1, 0, 0, 0, 0, time.UTC) + deletedTimeEntry.CreatedAt = time.Date(2023, 8, 1, 0, 0, 0, 0, time.UTC) + err = handlerTest.TimeEntryUsecase.AddTimeEntry(&deletedTimeEntry) + assert.Nil(t, err) + err = handlerTest.TimeEntryUsecase.DeleteTimeEntry(deletedTimeEntry.ID) + assert.Nil(t, err) + + w := httptest.NewRecorder() + + req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/sync/changed/%v", time.Now().Unix()), nil) + handlerTest.Router.ServeHTTP(w, req) + assert.Equal(t, 200, w.Code) + + var syncEntries SyncEntries + json.Unmarshal(w.Body.Bytes(), &syncEntries) + assert.Equal(t, 2, len(syncEntries.TimeEntries)) + + assert.Equal(t, deletedTimeEntry.Description, syncEntries.TimeEntries[0].Description) + assert.Equal(t, deletedTimeEntry.StartTime, time.Unix(syncEntries.TimeEntries[0].StartTimeUTCUnix, 0).UTC()) + assert.Equal(t, deletedTimeEntry.EndTime, time.Unix(syncEntries.TimeEntries[0].EndTimeUTCUnix, 0).UTC()) + assert.Equal(t, deletedTimeEntry.ProjectId, syncEntries.TimeEntries[0].ProjectId) + assert.Equal(t, DELETED, syncEntries.TimeEntries[0].ChangeType) + + assert.Equal(t, updatedTimeEntry.Description, syncEntries.TimeEntries[1].Description) + assert.Equal(t, updatedTimeEntry.StartTime, time.Unix(syncEntries.TimeEntries[1].StartTimeUTCUnix, 0).UTC()) + assert.Equal(t, updatedTimeEntry.EndTime, time.Unix(syncEntries.TimeEntries[1].EndTimeUTCUnix, 0).UTC()) + assert.Equal(t, updatedTimeEntry.ProjectId, syncEntries.TimeEntries[1].ProjectId) + assert.Equal(t, CHANGED, syncEntries.TimeEntries[1].ChangeType) +} + +func Test_syncHandler_SendNewLocalTimeEntries(t *testing.T) { + userId, err := uuid.NewV4() + assert.Nil(t, err) + token := authTokenMock{} + token.On("GetUserId").Return(userId, nil) + token.On("HasRole", model.RoleUser).Return(true, nil) + token.On("HasRole", model.RoleAdmin).Return(false, nil) + + verifier := tokenVerifierMock{} + verifier.On("VerifyToken", mock.Anything).Return(&token, nil) + + handlerTest := NewHandlerTest(&verifier) + teardownTest := handlerTest.SetupTest(t) + defer teardownTest(t) + + project := model.Project{ + Name: "project", + UserId: userId, + } + err = handlerTest.ProjectUsecase.AddProject(&project) + assert.Nil(t, err) + + startTime := time.Date(2023, 1, 28, 11, 0, 0, 0, time.UTC) + endTime := time.Date(2023, 1, 28, 11, 1, 0, 0, time.UTC) + id, err := uuid.NewV4() + assert.Nil(t, err) + + timeEntry1 := ChangedTimeEntryDto{ + Id: id, + Description: "timeEntry1", + StartTimeUTCUnix: startTime.Unix(), + EndTimeUTCUnix: endTime.Unix(), + ProjectId: project.ID, + ChangeType: NEW, + } + + syncEntries := SyncEntries{ + TimeEntries: []ChangedTimeEntryDto{timeEntry1}, + } + entryJson, err := json.Marshal(syncEntries) + assert.Nil(t, err) + + w := httptest.NewRecorder() + + entryReader := bytes.NewReader(entryJson) + req, _ := http.NewRequest("POST", "/api/v1/sync/changed", entryReader) + handlerTest.Router.ServeHTTP(w, req) + assert.Equal(t, 200, w.Code) + + entries, err := handlerTest.TimeEntryUsecase.GetAllTimeEntriesOfUser(userId) + assert.Nil(t, err) + assert.Equal(t, 1, len(entries)) + assert.Equal(t, id, entries[0].ID) + assert.Equal(t, "timeEntry1", entries[0].Description) + assert.Equal(t, startTime, entries[0].StartTime) + assert.Equal(t, endTime, entries[0].EndTime) + assert.Equal(t, project.ID, entries[0].ProjectId) +} + +func Test_syncHandler_SendUpdatedLocalTimeEntries(t *testing.T) { + userId, err := uuid.NewV4() + assert.Nil(t, err) + token := authTokenMock{} + token.On("GetUserId").Return(userId, nil) + token.On("HasRole", model.RoleUser).Return(true, nil) + token.On("HasRole", model.RoleAdmin).Return(false, nil) + + verifier := tokenVerifierMock{} + verifier.On("VerifyToken", mock.Anything).Return(&token, nil) + + handlerTest := NewHandlerTest(&verifier) + teardownTest := handlerTest.SetupTest(t) + defer teardownTest(t) + + project := model.Project{ + Name: "project", + UserId: userId, + } + err = handlerTest.ProjectUsecase.AddProject(&project) + assert.Nil(t, err) + + startTime := time.Date(2023, 1, 28, 11, 0, 0, 0, time.UTC) + endTime := time.Date(2023, 1, 28, 11, 1, 0, 0, time.UTC) + + // Create a time entry: + timeEntry := model.TimeEntry{ + Description: "timeentry", + StartTime: startTime, + EndTime: endTime, + ProjectId: project.ID, + UserId: userId, + } + err = handlerTest.TimeEntryUsecase.AddTimeEntry(&timeEntry) + assert.Nil(t, err) + + // Now let's update the time entry: + changeTime := time.Now().Add(time.Hour).UTC() + updatedTimeEntry := ChangedTimeEntryDto{ + Id: timeEntry.ID, + Description: "updatedTimeEntry", + StartTimeUTCUnix: startTime.Unix(), + EndTimeUTCUnix: endTime.Unix(), + ProjectId: project.ID, + ChangeType: CHANGED, + ChangeTimestampUTCUnix: changeTime.Unix(), + } + + syncEntries := SyncEntries{ + TimeEntries: []ChangedTimeEntryDto{updatedTimeEntry}, + } + entryJson, err := json.Marshal(syncEntries) + assert.Nil(t, err) + + w := httptest.NewRecorder() + + entryReader := bytes.NewReader(entryJson) + req, _ := http.NewRequest("POST", "/api/v1/sync/changed", entryReader) + handlerTest.Router.ServeHTTP(w, req) + assert.Equal(t, 200, w.Code) + + entries, err := handlerTest.TimeEntryUsecase.GetAllTimeEntriesOfUser(userId) + assert.Nil(t, err) + assert.Equal(t, 1, len(entries)) + assert.Equal(t, timeEntry.ID, entries[0].ID) + assert.Equal(t, "updatedTimeEntry", entries[0].Description) + assert.Equal(t, startTime, entries[0].StartTime) + assert.Equal(t, endTime, entries[0].EndTime) + assert.Equal(t, project.ID, entries[0].ProjectId) +} + +func Test_syncHandler_SendDeletedLocalTimeEntries(t *testing.T) { + userId, err := uuid.NewV4() + assert.Nil(t, err) + token := authTokenMock{} + token.On("GetUserId").Return(userId, nil) + token.On("HasRole", model.RoleUser).Return(true, nil) + token.On("HasRole", model.RoleAdmin).Return(false, nil) + + verifier := tokenVerifierMock{} + verifier.On("VerifyToken", mock.Anything).Return(&token, nil) + + handlerTest := NewHandlerTest(&verifier) + teardownTest := handlerTest.SetupTest(t) + defer teardownTest(t) + + project := model.Project{ + Name: "project", + UserId: userId, + } + err = handlerTest.ProjectUsecase.AddProject(&project) + assert.Nil(t, err) + + startTime := time.Date(2023, 1, 28, 11, 0, 0, 0, time.UTC) + endTime := time.Date(2023, 1, 28, 11, 1, 0, 0, time.UTC) + + // Create a time entry: + timeEntry := model.TimeEntry{ + Description: "timeentry", + StartTime: startTime, + EndTime: endTime, + ProjectId: project.ID, + UserId: userId, + } + err = handlerTest.TimeEntryUsecase.AddTimeEntry(&timeEntry) + assert.Nil(t, err) + + // Now let's delete the time entry: + changeTime := time.Now().Add(time.Hour).UTC() + deletedTimeEntry := ChangedTimeEntryDto{ + Id: timeEntry.ID, + Description: "deletedTimeEntry", + StartTimeUTCUnix: startTime.Unix(), + EndTimeUTCUnix: endTime.Unix(), + ProjectId: project.ID, + ChangeType: DELETED, + ChangeTimestampUTCUnix: changeTime.Unix(), + } + + syncEntries := SyncEntries{ + TimeEntries: []ChangedTimeEntryDto{deletedTimeEntry}, + } + entryJson, err := json.Marshal(syncEntries) + assert.Nil(t, err) + + w := httptest.NewRecorder() + + entryReader := bytes.NewReader(entryJson) + req, _ := http.NewRequest("POST", "/api/v1/sync/changed", entryReader) + handlerTest.Router.ServeHTTP(w, req) + assert.Equal(t, 200, w.Code) + + entries, err := handlerTest.TimeEntryUsecase.GetAllTimeEntriesOfUser(userId) + assert.Nil(t, err) + assert.Equal(t, 0, len(entries)) +} + +func Test_syncHandler_GetChangedProjects(t *testing.T) { + userId, err := uuid.NewV4() + assert.Nil(t, err) + token := authTokenMock{} + token.On("GetUserId").Return(userId, nil) + token.On("HasRole", model.RoleUser).Return(true, nil) + token.On("HasRole", model.RoleAdmin).Return(false, nil) + + verifier := tokenVerifierMock{} + verifier.On("VerifyToken", mock.Anything).Return(&token, nil) + + handlerTest := NewHandlerTest(&verifier) + teardownTest := handlerTest.SetupTest(t) + defer teardownTest(t) + + unchangedProject := model.Project{ + Name: "unchanged_project", + UserId: userId, + } + unchangedProject.UpdatedAt = time.Date(2023, 8, 1, 0, 0, 0, 0, time.UTC) + unchangedProject.CreatedAt = time.Date(2023, 8, 1, 0, 0, 0, 0, time.UTC) + err = handlerTest.ProjectUsecase.AddProject(&unchangedProject) + assert.Nil(t, err) + + updatedProject := model.Project{ + Name: "original_project", + UserId: userId, + } + updatedProject.UpdatedAt = time.Date(2023, 8, 1, 0, 0, 0, 0, time.UTC) + updatedProject.CreatedAt = time.Date(2023, 8, 1, 0, 0, 0, 0, time.UTC) + err = handlerTest.ProjectUsecase.AddProject(&updatedProject) + assert.Nil(t, err) + updatedProject.Name = "updated_timeetry" + err = handlerTest.ProjectUsecase.UpdateProject(&updatedProject) + assert.Nil(t, err) + + deletedProject := model.Project{ + Name: "deleted_project", + UserId: userId, + } + deletedProject.UpdatedAt = time.Date(2023, 8, 1, 0, 0, 0, 0, time.UTC) + deletedProject.CreatedAt = time.Date(2023, 8, 1, 0, 0, 0, 0, time.UTC) + err = handlerTest.ProjectUsecase.AddProject(&deletedProject) + assert.Nil(t, err) + err = handlerTest.ProjectUsecase.DeleteProject(deletedProject.ID) + assert.Nil(t, err) + + w := httptest.NewRecorder() + + req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/sync/changed/%v", time.Now().Unix()), nil) + handlerTest.Router.ServeHTTP(w, req) + assert.Equal(t, 200, w.Code) + + var syncEntries SyncEntries + json.Unmarshal(w.Body.Bytes(), &syncEntries) + assert.Equal(t, 2, len(syncEntries.Projects)) + + assert.Equal(t, deletedProject.Name, syncEntries.Projects[0].Name) + assert.Equal(t, DELETED, syncEntries.Projects[0].ChangeType) + + assert.Equal(t, updatedProject.Name, syncEntries.Projects[1].Name) + assert.Equal(t, CHANGED, syncEntries.Projects[1].ChangeType) +} diff --git a/server/src/pkg/usecase/sync_usecase.go b/server/src/pkg/usecase/sync_usecase.go new file mode 100644 index 0000000..80c9f3a --- /dev/null +++ b/server/src/pkg/usecase/sync_usecase.go @@ -0,0 +1,37 @@ +package usecase + +import ( + "time" + "timeasy-server/pkg/domain/model" + "timeasy-server/pkg/domain/repository" + + "github.com/gofrs/uuid" +) + +type SyncUsecase interface { + UpdateAndDeleteData(data model.SyncData) error + GetChangedTimeEntries(userId uuid.UUID, sinceWhen time.Time) ([]model.TimeEntry, error) + GetChangedProjects(userId uuid.UUID, sinceWhen time.Time) ([]model.Project, error) +} + +type syncUsecase struct { + repo repository.SyncRepository +} + +func NewSyncUsecase(repo repository.SyncRepository) SyncUsecase { + return &syncUsecase{ + repo: repo, + } +} + +func (usecase *syncUsecase) UpdateAndDeleteData(data model.SyncData) error { + return usecase.repo.UpdateAndDeleteData(data) +} + +func (tu *syncUsecase) GetChangedTimeEntries(userId uuid.UUID, sinceWhen time.Time) ([]model.TimeEntry, error) { + return tu.repo.GetUpdatedTimeEntriesOfUser(userId, sinceWhen) +} + +func (tu *syncUsecase) GetChangedProjects(userId uuid.UUID, sinceWhen time.Time) ([]model.Project, error) { + return tu.repo.GetUpdatedProjectsOfUser(userId, sinceWhen) +} diff --git a/server/src/pkg/usecase/sync_usecase_test.go b/server/src/pkg/usecase/sync_usecase_test.go new file mode 100644 index 0000000..4eaee54 --- /dev/null +++ b/server/src/pkg/usecase/sync_usecase_test.go @@ -0,0 +1,204 @@ +package usecase + +import ( + "testing" + "time" + "timeasy-server/pkg/domain/model" + + "github.com/stretchr/testify/assert" +) + +func Test_syncUsecase_CanUpdatedEntriesBeFetchedWhenEntryIsNew(t *testing.T) { + usecaseTest := NewUsecaseTest() + teardownTest := usecaseTest.SetupTest(t) + defer teardownTest(t) + + userId := GetTestUserId(t) + project := addProject(t, usecaseTest.ProjectUsecase, "project", userId) + + oldTimeEntry := model.TimeEntry{ + Description: "timeentry", + StartTime: time.Now(), + UserId: userId, + ProjectId: project.ID, + } + oldTimeEntry.UpdatedAt = time.Date(2023, 8, 1, 0, 0, 0, 0, time.UTC) + oldTimeEntry.CreatedAt = time.Date(2023, 8, 1, 0, 0, 0, 0, time.UTC) + err := usecaseTest.TimeEntryUsecase.AddTimeEntry(&oldTimeEntry) + assert.Nil(t, err) + + newTimeEntry := model.TimeEntry{ + Description: "newTimeEntry", + StartTime: time.Now(), + UserId: userId, + ProjectId: project.ID, + } + err = usecaseTest.TimeEntryUsecase.AddTimeEntry(&newTimeEntry) + assert.Nil(t, err) + + changedEntries, err := usecaseTest.SyncUsecase.GetChangedTimeEntries(userId, time.Date(2023, 8, 31, 0, 0, 0, 0, time.UTC)) + assert.Nil(t, err) + assert.Equal(t, 1, len(changedEntries)) + assert.Equal(t, "newTimeEntry", changedEntries[0].Description) +} + +func Test_syncUsecase_CanUpdatedEntriesBeFetchedWhenEntryIsUpdated(t *testing.T) { + usecaseTest := NewUsecaseTest() + teardownTest := usecaseTest.SetupTest(t) + defer teardownTest(t) + + userId := GetTestUserId(t) + project := addProject(t, usecaseTest.ProjectUsecase, "project", userId) + + oldTimeEntry := model.TimeEntry{ + Description: "timeentry", + StartTime: time.Now(), + UserId: userId, + ProjectId: project.ID, + } + oldTimeEntry.UpdatedAt = time.Date(2023, 8, 1, 0, 0, 0, 0, time.UTC) + oldTimeEntry.CreatedAt = time.Date(2023, 8, 1, 0, 0, 0, 0, time.UTC) + err := usecaseTest.TimeEntryUsecase.AddTimeEntry(&oldTimeEntry) + assert.Nil(t, err) + + // The entry should not be returned now: + changedEntries, err := usecaseTest.SyncUsecase.GetChangedTimeEntries(userId, time.Date(2023, 8, 31, 0, 0, 0, 0, time.UTC)) + assert.Nil(t, err) + assert.Equal(t, 0, len(changedEntries)) + + //Update the timeentry: + oldTimeEntry.Description = "updatedTimeEntry" + err = usecaseTest.TimeEntryUsecase.UpdateTimeEntry(&oldTimeEntry) + assert.Nil(t, err) + + changedEntries, err = usecaseTest.SyncUsecase.GetChangedTimeEntries(userId, time.Date(2023, 8, 31, 0, 0, 0, 0, time.UTC)) + assert.Nil(t, err) + assert.Equal(t, 1, len(changedEntries)) + assert.Equal(t, "updatedTimeEntry", changedEntries[0].Description) +} + +func Test_syncUsecase_CanUpdatedEntriesBeFetchedWhenEntryIsDeleted(t *testing.T) { + usecaseTest := NewUsecaseTest() + teardownTest := usecaseTest.SetupTest(t) + defer teardownTest(t) + + userId := GetTestUserId(t) + project := addProject(t, usecaseTest.ProjectUsecase, "project", userId) + + oldTimeEntry := model.TimeEntry{ + Description: "timeentry", + StartTime: time.Now(), + UserId: userId, + ProjectId: project.ID, + } + oldTimeEntry.UpdatedAt = time.Date(2023, 8, 1, 0, 0, 0, 0, time.UTC) + oldTimeEntry.CreatedAt = time.Date(2023, 8, 1, 0, 0, 0, 0, time.UTC) + err := usecaseTest.TimeEntryUsecase.AddTimeEntry(&oldTimeEntry) + assert.Nil(t, err) + + // The entry should not be returned now: + changedEntries, err := usecaseTest.SyncUsecase.GetChangedTimeEntries(userId, time.Date(2023, 8, 31, 0, 0, 0, 0, time.UTC)) + assert.Nil(t, err) + assert.Equal(t, 0, len(changedEntries)) + + //Delete the timeentry: + err = usecaseTest.TimeEntryUsecase.DeleteTimeEntry(oldTimeEntry.ID) + assert.Nil(t, err) + + changedEntries, err = usecaseTest.SyncUsecase.GetChangedTimeEntries(userId, time.Date(2023, 8, 31, 0, 0, 0, 0, time.UTC)) + assert.Nil(t, err) + assert.Equal(t, 1, len(changedEntries)) + assert.Equal(t, "timeentry", changedEntries[0].Description) +} + +func Test_syncUsecase_CanUpdatedProjectsBeFetchedWhenEntryIsNew(t *testing.T) { + usecaseTest := NewUsecaseTest() + teardownTest := usecaseTest.SetupTest(t) + defer teardownTest(t) + + userId := GetTestUserId(t) + + oldProject := model.Project{ + Name: "project", + UserId: userId, + } + oldProject.UpdatedAt = time.Date(2023, 8, 1, 0, 0, 0, 0, time.UTC) + oldProject.CreatedAt = time.Date(2023, 8, 1, 0, 0, 0, 0, time.UTC) + err := usecaseTest.ProjectUsecase.AddProject(&oldProject) + assert.Nil(t, err) + + newProject := model.Project{ + Name: "newProject", + UserId: userId, + } + err = usecaseTest.ProjectUsecase.AddProject(&newProject) + assert.Nil(t, err) + + changedProjects, err := usecaseTest.SyncUsecase.GetChangedProjects(userId, time.Date(2023, 8, 31, 0, 0, 0, 0, time.UTC)) + assert.Nil(t, err) + assert.Equal(t, 1, len(changedProjects)) + assert.Equal(t, "newProject", changedProjects[0].Name) +} + +func Test_syncUsecase_CanUpdatedProjectsBeFetchedWhenEntryIsUpdated(t *testing.T) { + usecaseTest := NewUsecaseTest() + teardownTest := usecaseTest.SetupTest(t) + defer teardownTest(t) + + userId := GetTestUserId(t) + + oldProject := model.Project{ + Name: "project", + UserId: userId, + } + oldProject.UpdatedAt = time.Date(2023, 8, 1, 0, 0, 0, 0, time.UTC) + oldProject.CreatedAt = time.Date(2023, 8, 1, 0, 0, 0, 0, time.UTC) + err := usecaseTest.ProjectUsecase.AddProject(&oldProject) + assert.Nil(t, err) + + // The project should not be returned now: + changedProjects, err := usecaseTest.SyncUsecase.GetChangedProjects(userId, time.Date(2023, 8, 31, 0, 0, 0, 0, time.UTC)) + assert.Nil(t, err) + assert.Equal(t, 0, len(changedProjects)) + + //Update the project: + oldProject.Name = "updatedProject" + err = usecaseTest.ProjectUsecase.UpdateProject(&oldProject) + assert.Nil(t, err) + + changedProjects, err = usecaseTest.SyncUsecase.GetChangedProjects(userId, time.Date(2023, 8, 31, 0, 0, 0, 0, time.UTC)) + assert.Nil(t, err) + assert.Equal(t, 1, len(changedProjects)) + assert.Equal(t, "updatedProject", changedProjects[0].Name) +} + +func Test_syncUsecase_CanUpdatedProjectsBeFetchedWhenEntryIsDeleted(t *testing.T) { + usecaseTest := NewUsecaseTest() + teardownTest := usecaseTest.SetupTest(t) + defer teardownTest(t) + + userId := GetTestUserId(t) + + oldProject := model.Project{ + Name: "project", + UserId: userId, + } + oldProject.UpdatedAt = time.Date(2023, 8, 1, 0, 0, 0, 0, time.UTC) + oldProject.CreatedAt = time.Date(2023, 8, 1, 0, 0, 0, 0, time.UTC) + err := usecaseTest.ProjectUsecase.AddProject(&oldProject) + assert.Nil(t, err) + + // The project should not be returned now: + changedProjects, err := usecaseTest.SyncUsecase.GetChangedProjects(userId, time.Date(2023, 8, 31, 0, 0, 0, 0, time.UTC)) + assert.Nil(t, err) + assert.Equal(t, 0, len(changedProjects)) + + //Delete the project: + err = usecaseTest.ProjectUsecase.DeleteProject(oldProject.ID) + assert.Nil(t, err) + + changedProjects, err = usecaseTest.SyncUsecase.GetChangedProjects(userId, time.Date(2023, 8, 31, 0, 0, 0, 0, time.UTC)) + assert.Nil(t, err) + assert.Equal(t, 1, len(changedProjects)) + assert.Equal(t, "project", changedProjects[0].Name) +} diff --git a/server/src/pkg/usecase/timeentry_usecase.go b/server/src/pkg/usecase/timeentry_usecase.go index 6df4f90..8cdca60 100644 --- a/server/src/pkg/usecase/timeentry_usecase.go +++ b/server/src/pkg/usecase/timeentry_usecase.go @@ -13,7 +13,9 @@ type TimeEntryUsecase interface { GetAllTimeEntriesOfUser(userId uuid.UUID) ([]model.TimeEntry, error) GetAllTimeEntriesOfUserAndProject(userId uuid.UUID, projectId uuid.UUID) ([]model.TimeEntry, error) AddTimeEntry(timeEntry *model.TimeEntry) error + AddTimeEntryList(timeEntryList []model.TimeEntry) error UpdateTimeEntry(timeEntry *model.TimeEntry) error + UpdateTimeEntryList(timeEntry []model.TimeEntry) error DeleteTimeEntry(id uuid.UUID) error } @@ -53,6 +55,16 @@ func (tu *timeEntryUsecase) AddTimeEntry(timeEntry *model.TimeEntry) error { return tu.repo.AddTimeEntry(timeEntry) } +func (tu *timeEntryUsecase) AddTimeEntryList(timeEntryList []model.TimeEntry) error { + for _, timeEntry := range timeEntryList { + err := tu.checkEntry(&timeEntry) + if err != nil { + return err + } + } + return tu.repo.AddTimeEntryList(timeEntryList) +} + func (tu *timeEntryUsecase) UpdateTimeEntry(timeEntry *model.TimeEntry) error { _, err := tu.GetTimeEntryById(timeEntry.ID) if err != nil { @@ -65,6 +77,16 @@ func (tu *timeEntryUsecase) UpdateTimeEntry(timeEntry *model.TimeEntry) error { return tu.repo.UpdateTimeEntry(timeEntry) } +func (tu *timeEntryUsecase) UpdateTimeEntryList(timeEntryList []model.TimeEntry) error { + for _, timeEntry := range timeEntryList { + err := tu.checkEntry(&timeEntry) + if err != nil { + return err + } + } + return tu.repo.UpdateTimeEntryList(timeEntryList) +} + func (tu *timeEntryUsecase) DeleteTimeEntry(id uuid.UUID) error { timeEntry, err := tu.GetTimeEntryById(id) if err != nil { @@ -87,14 +109,14 @@ func (tu *timeEntryUsecase) checkEntry(timeEntry *model.TimeEntry) error { func (tu *timeEntryUsecase) checkUser(timeEntry *model.TimeEntry) error { if timeEntry.UserId == uuid.Nil { - return NewEntityIncompleteError("the user id must not be empty") + return NewEntityIncompleteError(fmt.Sprintf("the user id of time entry %v must not be empty", timeEntry.ID)) } return nil } func (tu *timeEntryUsecase) checkProject(timeEntry *model.TimeEntry) error { if timeEntry.ProjectId == uuid.Nil { - return NewEntityIncompleteError("the project id must not be empty") + return NewEntityIncompleteError(fmt.Sprintf("the project id of time entry %v must not be empty", timeEntry.ID)) } _, err := tu.projectUsecase.GetProjectById(timeEntry.ProjectId) if err != nil { diff --git a/server/src/pkg/usecase/timeentry_usecase_test.go b/server/src/pkg/usecase/timeentry_usecase_test.go index 8746265..405142e 100644 --- a/server/src/pkg/usecase/timeentry_usecase_test.go +++ b/server/src/pkg/usecase/timeentry_usecase_test.go @@ -59,6 +59,157 @@ func Test_timeEntryUsecase_AddTimeEntryFailsIfProjectDoesNotExist(t *testing.T) assert.True(t, errors.As(err, &projectNotFoundError)) } +func Test_timeEntryUsecase_AddTimeEntryList(t *testing.T) { + usecaseTest := NewUsecaseTest() + teardownTest := usecaseTest.SetupTest(t) + defer teardownTest(t) + + userId := GetTestUserId(t) + project := addProject(t, usecaseTest.ProjectUsecase, "project", userId) + + timeEntry1 := model.TimeEntry{ + Description: "timeentry", + StartTime: time.Now().Add(time.Hour), + UserId: userId, + ProjectId: project.ID, + } + timeEntry2 := model.TimeEntry{ + Description: "timeentry2", + StartTime: time.Now(), + UserId: userId, + ProjectId: project.ID, + } + + addedTimeEntries := []model.TimeEntry{ + timeEntry1, + timeEntry2, + } + + err := usecaseTest.TimeEntryUsecase.AddTimeEntryList(addedTimeEntries) + assert.Nil(t, err) + + entryList, err := usecaseTest.TimeEntryUsecase.GetAllTimeEntriesOfUser(userId) + assert.Nil(t, err) + assert.Equal(t, 2, len(entryList)) + for i, timeEntry := range entryList { + assert.Equal(t, addedTimeEntries[i].Description, timeEntry.Description) + assertTimesAreEqual(t, addedTimeEntries[i].StartTime, timeEntry.StartTime) + assert.Equal(t, userId, timeEntry.UserId) + assert.Equal(t, project.ID, timeEntry.ProjectId) + assert.True(t, timeEntry.EndTime.IsZero()) + } +} + +func Test_timeEntryUsecase_AddTimeEntryListFailsIfProjectIdIsMissing(t *testing.T) { + usecaseTest := NewUsecaseTest() + teardownTest := usecaseTest.SetupTest(t) + defer teardownTest(t) + + userId := GetTestUserId(t) + project := addProject(t, usecaseTest.ProjectUsecase, "project", userId) + + timeEntry1 := model.TimeEntry{ + Description: "timeentry", + StartTime: time.Now().Add(time.Hour), + UserId: userId, + ProjectId: project.ID, + } + timeEntry2 := model.TimeEntry{ + Description: "timeentry2", + StartTime: time.Now(), + UserId: userId, + } + + addedTimeEntries := []model.TimeEntry{ + timeEntry1, + timeEntry2, + } + + err := usecaseTest.TimeEntryUsecase.AddTimeEntryList(addedTimeEntries) + assert.NotNil(t, err) + var entityIncompleteError *EntityIncompleteError + assert.True(t, errors.As(err, &entityIncompleteError)) + assert.Equal(t, fmt.Sprintf("the project id of time entry %v must not be empty", timeEntry2.ID), err.Error()) + + entryList, err := usecaseTest.TimeEntryUsecase.GetAllTimeEntriesOfUser(userId) + assert.Nil(t, err) + assert.Equal(t, 0, len(entryList)) +} + +func Test_timeEntryUsecase_AddTimeEntryListFailsIfUserIdIsMissing(t *testing.T) { + usecaseTest := NewUsecaseTest() + teardownTest := usecaseTest.SetupTest(t) + defer teardownTest(t) + + userId := GetTestUserId(t) + project := addProject(t, usecaseTest.ProjectUsecase, "project", userId) + + timeEntry1 := model.TimeEntry{ + Description: "timeentry", + StartTime: time.Now().Add(time.Hour), + UserId: userId, + ProjectId: project.ID, + } + timeEntry2 := model.TimeEntry{ + Description: "timeentry2", + StartTime: time.Now(), + ProjectId: project.ID, + } + + addedTimeEntries := []model.TimeEntry{ + timeEntry1, + timeEntry2, + } + + err := usecaseTest.TimeEntryUsecase.AddTimeEntryList(addedTimeEntries) + assert.NotNil(t, err) + var entityIncompleteError *EntityIncompleteError + assert.True(t, errors.As(err, &entityIncompleteError)) + assert.Equal(t, fmt.Sprintf("the user id of time entry %v must not be empty", timeEntry2.ID), err.Error()) + + entryList, err := usecaseTest.TimeEntryUsecase.GetAllTimeEntriesOfUser(userId) + assert.Nil(t, err) + assert.Equal(t, 0, len(entryList)) +} + +func Test_timeEntryUsecase_AddTimeEntryListFailsIfProjectIsMissing(t *testing.T) { + usecaseTest := NewUsecaseTest() + teardownTest := usecaseTest.SetupTest(t) + defer teardownTest(t) + + userId := GetTestUserId(t) + project := addProject(t, usecaseTest.ProjectUsecase, "project", userId) + + timeEntry1 := model.TimeEntry{ + Description: "timeentry", + StartTime: time.Now().Add(time.Hour), + UserId: userId, + ProjectId: project.ID, + } + notExistingProjectId, err := uuid.NewV4() + timeEntry2 := model.TimeEntry{ + Description: "timeentry2", + StartTime: time.Now(), + UserId: userId, + ProjectId: notExistingProjectId, + } + + addedTimeEntries := []model.TimeEntry{ + timeEntry1, + timeEntry2, + } + + err = usecaseTest.TimeEntryUsecase.AddTimeEntryList(addedTimeEntries) + assert.NotNil(t, err) + var projectNotFoundError *ProjectNotFoundError + assert.True(t, errors.As(err, &projectNotFoundError)) + assert.Equal(t, fmt.Sprintf("project with id %v not found", notExistingProjectId), err.Error()) + + entryList, err := usecaseTest.TimeEntryUsecase.GetAllTimeEntriesOfUser(userId) + assert.Nil(t, err) + assert.Equal(t, 0, len(entryList)) +} + func Test_timeEntryUsecase_GetTimeEntryById(t *testing.T) { usecaseTest := NewUsecaseTest() teardownTest := usecaseTest.SetupTest(t) @@ -314,6 +465,213 @@ func Test_timeEntryUsecase_UpdateTimeEntryFailsIfProjectDoesNotExist(t *testing. assert.True(t, entryList[0].EndTime.IsZero()) } +func Test_timeEntryUsecase_UpdateTimeEntryList(t *testing.T) { + usecaseTest := NewUsecaseTest() + teardownTest := usecaseTest.SetupTest(t) + defer teardownTest(t) + + userId := GetTestUserId(t) + project := addProject(t, usecaseTest.ProjectUsecase, "project", userId) + + timeEntry1 := model.TimeEntry{ + Description: "timeentry1", + StartTime: time.Now().Add(time.Hour), + UserId: userId, + ProjectId: project.ID, + } + timeEntry2 := model.TimeEntry{ + Description: "timeentry2", + StartTime: time.Now(), + UserId: userId, + ProjectId: project.ID, + } + timeEntries := []model.TimeEntry{ + timeEntry1, + timeEntry2, + } + err := usecaseTest.TimeEntryUsecase.AddTimeEntryList(timeEntries) + assert.Nil(t, err) + + // fetch the time entries from the db again to get their proper ids: + timeEntries, err = usecaseTest.TimeEntryUsecase.GetAllTimeEntriesOfUser(userId) + assert.Nil(t, err) + timeEntries[0].Description = "updatedTimeentry1" + timeEntries[1].Description = "updatedTimeentry2" + err = usecaseTest.TimeEntryUsecase.UpdateTimeEntryList(timeEntries) + assert.Nil(t, err) + + entriesFromDb, err := usecaseTest.TimeEntryUsecase.GetAllTimeEntriesOfUser(userId) + assert.Nil(t, err) + assert.Equal(t, 2, len(entriesFromDb)) + for i, timeEntry := range entriesFromDb { + assert.Equal(t, timeEntries[i].Description, timeEntry.Description) + assertTimesAreEqual(t, timeEntries[i].StartTime, timeEntry.StartTime) + assert.Equal(t, timeEntries[i].UserId, timeEntry.UserId) + assert.Equal(t, timeEntries[i].ProjectId, timeEntry.ProjectId) + assert.True(t, timeEntry.EndTime.IsZero()) + } +} + +func Test_timeEntryUsecase_UpdateTimeEntryListFailsIfUserIdIsMissing(t *testing.T) { + usecaseTest := NewUsecaseTest() + teardownTest := usecaseTest.SetupTest(t) + defer teardownTest(t) + + userId := GetTestUserId(t) + project := addProject(t, usecaseTest.ProjectUsecase, "project", userId) + + timeEntry1 := model.TimeEntry{ + Description: "timeentry1", + StartTime: time.Now().Add(time.Hour), + UserId: userId, + ProjectId: project.ID, + } + timeEntry2 := model.TimeEntry{ + Description: "timeentry2", + StartTime: time.Now(), + UserId: userId, + ProjectId: project.ID, + } + timeEntries := []model.TimeEntry{ + timeEntry1, + timeEntry2, + } + err := usecaseTest.TimeEntryUsecase.AddTimeEntryList(timeEntries) + assert.Nil(t, err) + + // fetch the time entries from the db again to get their proper ids: + timeEntries, err = usecaseTest.TimeEntryUsecase.GetAllTimeEntriesOfUser(userId) + assert.Nil(t, err) + timeEntries[0].Description = "updatedTimeentry1" + timeEntries[1].Description = "updatedTimeentry2" + timeEntries[1].UserId = uuid.Nil + err = usecaseTest.TimeEntryUsecase.UpdateTimeEntryList(timeEntries) + assert.NotNil(t, err) + var entityIncompleteError *EntityIncompleteError + assert.True(t, errors.As(err, &entityIncompleteError)) + assert.Equal(t, fmt.Sprintf("the user id of time entry %v must not be empty", timeEntries[1].ID), err.Error()) + + entriesFromDb, err := usecaseTest.TimeEntryUsecase.GetAllTimeEntriesOfUser(userId) + assert.Nil(t, err) + + assert.Equal(t, 2, len(entriesFromDb)) + // No entry should be changed now: + for i, timeEntry := range entriesFromDb { + assert.Equal(t, fmt.Sprintf("timeentry%v", i+1), timeEntry.Description) + assertTimesAreEqual(t, timeEntries[i].StartTime, timeEntry.StartTime) + assert.Equal(t, userId, timeEntry.UserId) + assert.Equal(t, timeEntries[i].ProjectId, timeEntry.ProjectId) + assert.True(t, timeEntry.EndTime.IsZero()) + } +} + +func Test_timeEntryUsecase_UpdateTimeEntryListFailsIfProjectIdIsMissing(t *testing.T) { + usecaseTest := NewUsecaseTest() + teardownTest := usecaseTest.SetupTest(t) + defer teardownTest(t) + + userId := GetTestUserId(t) + project := addProject(t, usecaseTest.ProjectUsecase, "project", userId) + + timeEntry1 := model.TimeEntry{ + Description: "timeentry1", + StartTime: time.Now().Add(time.Hour), + UserId: userId, + ProjectId: project.ID, + } + timeEntry2 := model.TimeEntry{ + Description: "timeentry2", + StartTime: time.Now(), + UserId: userId, + ProjectId: project.ID, + } + timeEntries := []model.TimeEntry{ + timeEntry1, + timeEntry2, + } + err := usecaseTest.TimeEntryUsecase.AddTimeEntryList(timeEntries) + assert.Nil(t, err) + + // fetch the time entries from the db again to get their proper ids: + timeEntries, err = usecaseTest.TimeEntryUsecase.GetAllTimeEntriesOfUser(userId) + assert.Nil(t, err) + timeEntries[0].Description = "updatedTimeentry1" + timeEntries[1].Description = "updatedTimeentry2" + timeEntries[1].ProjectId = uuid.Nil + err = usecaseTest.TimeEntryUsecase.UpdateTimeEntryList(timeEntries) + assert.NotNil(t, err) + var entityIncompleteError *EntityIncompleteError + assert.True(t, errors.As(err, &entityIncompleteError)) + assert.Equal(t, fmt.Sprintf("the project id of time entry %v must not be empty", timeEntries[1].ID), err.Error()) + + entriesFromDb, err := usecaseTest.TimeEntryUsecase.GetAllTimeEntriesOfUser(userId) + assert.Nil(t, err) + + assert.Equal(t, 2, len(entriesFromDb)) + // No entry should be changed now: + for i, timeEntry := range entriesFromDb { + assert.Equal(t, fmt.Sprintf("timeentry%v", i+1), timeEntry.Description) + assertTimesAreEqual(t, timeEntries[i].StartTime, timeEntry.StartTime) + assert.Equal(t, userId, timeEntry.UserId) + assert.Equal(t, project.ID, timeEntry.ProjectId) + assert.True(t, timeEntry.EndTime.IsZero()) + } +} + +func Test_timeEntryUsecase_UpdateTimeEntryListFailsIfProjectIsMissing(t *testing.T) { + usecaseTest := NewUsecaseTest() + teardownTest := usecaseTest.SetupTest(t) + defer teardownTest(t) + + userId := GetTestUserId(t) + project := addProject(t, usecaseTest.ProjectUsecase, "project", userId) + + timeEntry1 := model.TimeEntry{ + Description: "timeentry1", + StartTime: time.Now().Add(time.Hour), + UserId: userId, + ProjectId: project.ID, + } + timeEntry2 := model.TimeEntry{ + Description: "timeentry2", + StartTime: time.Now(), + UserId: userId, + ProjectId: project.ID, + } + timeEntries := []model.TimeEntry{ + timeEntry1, + timeEntry2, + } + err := usecaseTest.TimeEntryUsecase.AddTimeEntryList(timeEntries) + assert.Nil(t, err) + + // fetch the time entries from the db again to get their proper ids: + timeEntries, err = usecaseTest.TimeEntryUsecase.GetAllTimeEntriesOfUser(userId) + assert.Nil(t, err) + timeEntries[0].Description = "updatedTimeentry1" + timeEntries[1].Description = "updatedTimeentry2" + missingProjectId, err := uuid.NewV4() + timeEntries[1].ProjectId = missingProjectId + err = usecaseTest.TimeEntryUsecase.UpdateTimeEntryList(timeEntries) + assert.NotNil(t, err) + var projectNotFoundError *ProjectNotFoundError + assert.True(t, errors.As(err, &projectNotFoundError)) + assert.Equal(t, fmt.Sprintf("project with id %v not found", missingProjectId), err.Error()) + + entriesFromDb, err := usecaseTest.TimeEntryUsecase.GetAllTimeEntriesOfUser(userId) + assert.Nil(t, err) + + assert.Equal(t, 2, len(entriesFromDb)) + // No entry should be changed now: + for i, timeEntry := range entriesFromDb { + assert.Equal(t, fmt.Sprintf("timeentry%v", i+1), timeEntry.Description) + assertTimesAreEqual(t, timeEntries[i].StartTime, timeEntry.StartTime) + assert.Equal(t, userId, timeEntry.UserId) + assert.Equal(t, project.ID, timeEntry.ProjectId) + assert.True(t, timeEntry.EndTime.IsZero()) + } +} + func Test_timeEntryUsecase_DeleteTimeEntry(t *testing.T) { usecaseTest := NewUsecaseTest() teardownTest := usecaseTest.SetupTest(t) diff --git a/server/src/pkg/usecase/usecase_test.go b/server/src/pkg/usecase/usecase_test.go index d38127b..9ae0d89 100644 --- a/server/src/pkg/usecase/usecase_test.go +++ b/server/src/pkg/usecase/usecase_test.go @@ -25,6 +25,7 @@ type UsecaseTest struct { ProjectUsecase ProjectUsecase TimeEntryUsecase TimeEntryUsecase TeamUsecase TeamUsecase + SyncUsecase SyncUsecase } func NewUsecaseTest() *UsecaseTest { @@ -46,6 +47,9 @@ func (u *UsecaseTest) initUsecases() { timeEntryRepo := database.NewGormTimeEntryRepository(test.DB) u.TimeEntryUsecase = NewTimeEntryUsecase(timeEntryRepo, u.ProjectUsecase) + + syncRepo := database.NewGormSyncRepository(test.DB) + u.SyncUsecase = NewSyncUsecase(syncRepo) } func GetTestUserId(t *testing.T) uuid.UUID {