From 0f9c12d077fed6598812d4c2d8ef5c0025043c7b Mon Sep 17 00:00:00 2001 From: Robin Dittmar Date: Fri, 24 Jul 2026 15:30:42 +0200 Subject: [PATCH 1/5] test: utilize testify --- go.mod | 5 +++++ internal/api/handler/health_test.go | 12 ++++-------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/go.mod b/go.mod index 5ab6841..fc3a96d 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/golang-migrate/migrate/v4 v4.19.1 github.com/jackc/pgx/v5 v5.10.0 github.com/joho/godotenv v1.5.1 + github.com/stretchr/testify v1.11.1 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 go.opentelemetry.io/otel v1.44.0 go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 @@ -20,6 +21,7 @@ require ( require ( github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/felixge/httpsnoop v1.1.0 // indirect github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect @@ -29,6 +31,8 @@ require ( github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/lib/pq v1.12.3 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/stretchr/objx v0.5.2 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect go.opentelemetry.io/otel/metric v1.44.0 // indirect @@ -41,4 +45,5 @@ require ( google.golang.org/genproto/googleapis/rpc v0.0.0-20260720171339-e059f2f05d78 // indirect google.golang.org/grpc v1.82.1 // indirect google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/internal/api/handler/health_test.go b/internal/api/handler/health_test.go index 0e68da8..2f6885e 100644 --- a/internal/api/handler/health_test.go +++ b/internal/api/handler/health_test.go @@ -5,6 +5,8 @@ import ( "net/http/httptest" "testing" + "github.com/stretchr/testify/assert" + "github.com/robindittmar/dttmr-api/internal/api/handler" ) @@ -14,12 +16,6 @@ func TestHealthHandler_Success(t *testing.T) { handler.HealthHandler(rr, req) - if rr.Code != http.StatusOK { - t.Errorf("HealthHandler returned wrong status code: got %v want %v", rr.Code, http.StatusOK) - } - - expected := `{"status":"ok"}` - if rr.Body.String() != expected { - t.Errorf("HealthHandler returned unexpected body: got %v want %v", rr.Body.String(), expected) - } + assert.Equal(t, http.StatusOK, rr.Code) + assert.Equal(t, `{"status":"ok"}`, rr.Body.String()) } -- 2.54.0 From c0ab4c9a62f000c775609bc3880c5fd13ea0691f Mon Sep 17 00:00:00 2001 From: Robin Dittmar Date: Fri, 24 Jul 2026 15:30:58 +0200 Subject: [PATCH 2/5] test: added tests for list service --- internal/domain/list_test.go | 84 ++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 internal/domain/list_test.go diff --git a/internal/domain/list_test.go b/internal/domain/list_test.go new file mode 100644 index 0000000..8d69e18 --- /dev/null +++ b/internal/domain/list_test.go @@ -0,0 +1,84 @@ +package domain_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/robindittmar/dttmr-api/internal/domain" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +type mockListRepo struct { + mock.Mock +} + +func (m *mockListRepo) CreateList(ctx context.Context, name string, userIDs []string) (*domain.List, error) { + args := m.Called(ctx, name, userIDs) + var list *domain.List + if l := args.Get(0); l != nil { + list = l.(*domain.List) + } + return list, args.Error(1) +} + +func TestListService_Create_Success(t *testing.T) { + expectedList := &domain.List{ + ID: "1", + Name: "My List", + CreatedAt: time.Now(), + ModifiedAt: time.Now(), + } + + repo := new(mockListRepo) + repo.On("CreateList", mock.Anything, "My List", []string{"user1", "user2"}).Return(expectedList, nil) + + service := domain.NewListService(repo) + list, err := service.Create(context.Background(), "My List", []string{"user1", "user2"}) + + require.NoError(t, err) + assert.Equal(t, expectedList, list) + repo.AssertExpectations(t) +} + +func TestListService_Create_EmptyName(t *testing.T) { + repo := new(mockListRepo) + service := domain.NewListService(repo) + + list, err := service.Create(context.Background(), "", []string{"user1"}) + + require.Error(t, err) + assert.EqualError(t, err, "list name must not be empty") + assert.Nil(t, list) + repo.AssertExpectations(t) +} + +func TestListService_Create_EmptyUsers(t *testing.T) { + repo := new(mockListRepo) + service := domain.NewListService(repo) + + list, err := service.Create(context.Background(), "My List", []string{}) + + require.Error(t, err) + assert.EqualError(t, err, "users must have at least one associated user") + assert.Nil(t, list) + repo.AssertExpectations(t) +} + +func TestListService_Create_RepoError(t *testing.T) { + expectedErr := errors.New("database error") + repo := new(mockListRepo) + repo.On("CreateList", mock.Anything, "My List", []string{"user1"}).Return(nil, expectedErr) + + service := domain.NewListService(repo) + + list, err := service.Create(context.Background(), "My List", []string{"user1"}) + + require.Error(t, err) + assert.ErrorIs(t, err, expectedErr) + assert.Nil(t, list) + repo.AssertExpectations(t) +} -- 2.54.0 From 031b586fe7dd6ccb8b679b7639c0bd06224bd1e7 Mon Sep 17 00:00:00 2001 From: Robin Dittmar Date: Fri, 24 Jul 2026 15:50:28 +0200 Subject: [PATCH 3/5] test: added two variants of tests for user repository --- go.mod | 1 + internal/repository/user_test.go | 189 +++++++++++++++++++++++++++++++ 2 files changed, 190 insertions(+) create mode 100644 internal/repository/user_test.go diff --git a/go.mod b/go.mod index fc3a96d..282a5c4 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/robindittmar/dttmr-api go 1.26 require ( + github.com/DATA-DOG/go-sqlmock v1.5.2 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/golang-migrate/migrate/v4 v4.19.1 github.com/jackc/pgx/v5 v5.10.0 diff --git a/internal/repository/user_test.go b/internal/repository/user_test.go new file mode 100644 index 0000000..659674a --- /dev/null +++ b/internal/repository/user_test.go @@ -0,0 +1,189 @@ +package repository + +import ( + "context" + "errors" + "fmt" + "regexp" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/robindittmar/dttmr-api/internal/domain" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestUserRepo_CreateUser(t *testing.T) { + db, mock, err := sqlmock.New() + assert.NoError(t, err) + defer db.Close() + + repo := NewUserRepo(db) + + ctx := context.Background() + email := "test@example.com" + name := "Test User" + passwordHash := "hashedpassword123" + + now := time.Now() + expectedUser := &domain.User{ + ID: "1", + Email: email, + Name: name, + CreatedAt: now, + } + + t.Run("success", func(t *testing.T) { + mock.ExpectBegin() + + mock.ExpectQuery(`^INSERT INTO users \(email, name, password_hash\) VALUES \(\$1, \$2, \$3\) RETURNING id, created_at$`). + WithArgs(email, name, passwordHash). + WillReturnRows(sqlmock.NewRows([]string{"id", "created_at"}).AddRow(expectedUser.ID, expectedUser.CreatedAt)) + + mock.ExpectCommit() + + user, err := repo.CreateUser(ctx, email, name, passwordHash) + assert.NoError(t, err) + assert.Equal(t, expectedUser, user) + assert.NoError(t, mock.ExpectationsWereMet()) + }) + + t.Run("begin_tx_error", func(t *testing.T) { + mock.ExpectBegin().WillReturnError(fmt.Errorf("tx error")) + + user, err := repo.CreateUser(ctx, email, name, passwordHash) + assert.Error(t, err) + assert.Contains(t, err.Error(), "begin transaction") + assert.Nil(t, user) + assert.NoError(t, mock.ExpectationsWereMet()) + }) + + t.Run("insert_error", func(t *testing.T) { + mock.ExpectBegin() + mock.ExpectQuery(`^INSERT INTO users \(email, name, password_hash\) VALUES \(\$1, \$2, \$3\) RETURNING id, created_at$`). + WithArgs(email, name, passwordHash). + WillReturnError(fmt.Errorf("insert error")) + mock.ExpectRollback() + + user, err := repo.CreateUser(ctx, email, name, passwordHash) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to insert user") + assert.Nil(t, user) + assert.NoError(t, mock.ExpectationsWereMet()) + }) + + t.Run("commit_error", func(t *testing.T) { + mock.ExpectBegin() + mock.ExpectQuery(`^INSERT INTO users \(email, name, password_hash\) VALUES \(\$1, \$2, \$3\) RETURNING id, created_at$`). + WithArgs(email, name, passwordHash). + WillReturnRows(sqlmock.NewRows([]string{"id", "created_at"}).AddRow(expectedUser.ID, expectedUser.CreatedAt)) + mock.ExpectCommit().WillReturnError(fmt.Errorf("commit error")) + + user, err := repo.CreateUser(ctx, email, name, passwordHash) + assert.Error(t, err) + assert.Contains(t, err.Error(), "commit transaction") + assert.Nil(t, user) + assert.NoError(t, mock.ExpectationsWereMet()) + }) +} + +func TestUserRepo_CreateUser2(t *testing.T) { + email := "test@example.com" + name := "Test User" + passwordHash := "hashedpassword123" + now := time.Now() + expectedID := "42" + + insertQuery := regexp.QuoteMeta( + "INSERT INTO users (email, name, password_hash) VALUES ($1, $2, $3) RETURNING id, created_at", + ) + + testCases := []struct { + name string + setupMock func(mock sqlmock.Sqlmock) + expectedError string + }{ + { + name: "Success: User created perfectly", + setupMock: func(mock sqlmock.Sqlmock) { + mock.ExpectBegin() + + rows := sqlmock.NewRows([]string{"id", "created_at"}). + AddRow(expectedID, now) + + mock.ExpectQuery(insertQuery). + WithArgs(email, name, passwordHash). + WillReturnRows(rows) + + mock.ExpectCommit() + }, + expectedError: "", + }, + { + name: "Failure: Database connection fails on BeginTx", + setupMock: func(mock sqlmock.Sqlmock) { + mock.ExpectBegin().WillReturnError(errors.New("db connection failed")) + }, + expectedError: "begin transaction: db connection failed", + }, + { + name: "Failure: Query fails (e.g., duplicate email)", + setupMock: func(mock sqlmock.Sqlmock) { + mock.ExpectBegin() + + mock.ExpectQuery(insertQuery). + WithArgs(email, name, passwordHash). + WillReturnError(errors.New("unique constraint violation")) + + mock.ExpectRollback() + }, + expectedError: "failed to insert user: unique constraint violation", + }, + { + name: "Failure: Commit fails (e.g., network timeout)", + setupMock: func(mock sqlmock.Sqlmock) { + mock.ExpectBegin() + + rows := sqlmock.NewRows([]string{"id", "created_at"}). + AddRow(expectedID, now) + + mock.ExpectQuery(insertQuery). + WithArgs(email, name, passwordHash). + WillReturnRows(rows) + + mock.ExpectCommit().WillReturnError(errors.New("commit timeout")) + }, + expectedError: "commit transaction: commit timeout", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer db.Close() + + tc.setupMock(mock) + + repo := NewUserRepo(db) + + user, err := repo.CreateUser(context.Background(), email, name, passwordHash) + + if tc.expectedError != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.expectedError) + assert.Nil(t, user) + } else { + require.NoError(t, err) + require.NotNil(t, user) + assert.Equal(t, expectedID, user.ID) + assert.Equal(t, email, user.Email) + assert.Equal(t, name, user.Name) + assert.Equal(t, now, user.CreatedAt) + } + + assert.NoError(t, mock.ExpectationsWereMet()) + }) + } +} -- 2.54.0 From cb14374d094d868be4f0c06eb2abf78236e7e025 Mon Sep 17 00:00:00 2001 From: Robin Dittmar Date: Tue, 28 Jul 2026 14:29:57 +0200 Subject: [PATCH 4/5] fix: added missing "error" key for logging --- cmd/api-server/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/api-server/main.go b/cmd/api-server/main.go index ce86612..f328afc 100644 --- a/cmd/api-server/main.go +++ b/cmd/api-server/main.go @@ -80,7 +80,7 @@ func run(serviceName string, serviceVersion string) error { go func() { slog.Info("starting http server", "addr", srv.Addr) if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { - slog.Error("failed to start http server", err) + slog.Error("failed to start http server", slog.Any("error", err)) os.Exit(1) } }() -- 2.54.0 From 2e1fbc6e6875c09a41b25bc4fd9aac3210218504 Mon Sep 17 00:00:00 2001 From: Robin Dittmar Date: Tue, 28 Jul 2026 14:41:49 +0200 Subject: [PATCH 5/5] feat: added swaggo support --- cmd/api-server/main.go | 17 +++++++++++++++++ internal/api/handler/auth.go | 12 ++++++++++++ internal/api/handler/default.go | 9 +++++++++ internal/api/handler/health.go | 9 +++++++++ internal/api/handler/list.go | 12 ++++++++++++ internal/api/handler/user.go | 12 ++++++++++++ internal/api/router/router.go | 15 +++++++++------ 7 files changed, 80 insertions(+), 6 deletions(-) diff --git a/cmd/api-server/main.go b/cmd/api-server/main.go index f328afc..ef53b8c 100644 --- a/cmd/api-server/main.go +++ b/cmd/api-server/main.go @@ -19,6 +19,23 @@ import ( "github.com/robindittmar/dttmr-api/internal/telemetry" ) +// @title dttmr-api +// @version 0.1.0 +// @description API documentation for dttmr-api service. +// @termsOfService http://swagger.io/terms/ + +// @contact.name Robin Dittmar +// @contact.email robindittmar@gmail.com + +// @license.name MIT +// @license.url https://opensource.org/licenses/MIT + +// @host localhost:8080 +// @BasePath /api/v1 + +// @securityDefinitions.apikey Bearer Token +// @in Header +// @name Authorization func main() { serviceName := "dttmr-api" serviceVersion := "0.1.0" diff --git a/internal/api/handler/auth.go b/internal/api/handler/auth.go index cc277ad..0573cbd 100644 --- a/internal/api/handler/auth.go +++ b/internal/api/handler/auth.go @@ -17,6 +17,18 @@ func NewAuthHandler(authService *domain.AuthService) *AuthHandler { return &AuthHandler{AuthService: authService} } +// Login handles the login of a user +// +// @Summary Login route +// @Description User authorization and token issuing +// @Tags Authorization +// @Accept json +// @Produce json +// @Param payload body request.LoginPayload true "Login payload" +// @Success 200 {object} domain.AuthToken +// @Error 400 {object} response.ErrorResponse "failed to decode request body" +// @Error 500 {object} response.ErrorResponse "failed to login" +// @Router /api/v1/login [post] func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) { ctx := r.Context() diff --git a/internal/api/handler/default.go b/internal/api/handler/default.go index a5f2f3a..fd19134 100644 --- a/internal/api/handler/default.go +++ b/internal/api/handler/default.go @@ -17,6 +17,15 @@ type apiResponse struct { Form map[string]string `json:"form"` } +// DefaultHandler handles the default route +// +// @Summary Default route handler +// @Description Default route handler +// @Tags +// @Accept json +// @Produce json +// @Success 200 {object} apiResponse +// @Router /api/v1/ [get] func DefaultHandler(w http.ResponseWriter, r *http.Request) { ctx := r.Context() diff --git a/internal/api/handler/health.go b/internal/api/handler/health.go index b56ca58..ebda574 100644 --- a/internal/api/handler/health.go +++ b/internal/api/handler/health.go @@ -10,6 +10,15 @@ type healthResponse struct { Status string `json:"status"` } +// HealthHandler handles the health check route +// +// @Summary Health check +// @Description Health check reports the status of the API +// @Tags Health +// @Accept json +// @Produce json +// @Success 200 {object} healthResponse +// @Router /health [get] func HealthHandler(w http.ResponseWriter, r *http.Request) { ctx := r.Context() diff --git a/internal/api/handler/list.go b/internal/api/handler/list.go index 987e8f3..2adbfc1 100644 --- a/internal/api/handler/list.go +++ b/internal/api/handler/list.go @@ -17,6 +17,18 @@ func NewListHandler(listService *domain.ListService) *ListHandler { return &ListHandler{ListService: listService} } +// CreateList handles the creation of a list +// +// @Summary Create list route +// @Description Create a list and associate user(s) to it +// @Tags List +// @Accept json +// @Produce json +// @Param payload body request.CreateListPayload true "Create list payload" +// @Success 201 {object} domain.List +// @Error 400 {object} response.ErrorResponse "failed to decode request body" +// @Error 500 {object} response.ErrorResponse "failed to create list" +// @Router /api/v1/list [post] func (h *ListHandler) CreateList(w http.ResponseWriter, r *http.Request) { ctx := r.Context() diff --git a/internal/api/handler/user.go b/internal/api/handler/user.go index e80b7d8..38753f7 100644 --- a/internal/api/handler/user.go +++ b/internal/api/handler/user.go @@ -17,6 +17,18 @@ func NewUserHandler(userService *domain.UserService) *UserHandler { return &UserHandler{UserService: userService} } +// CreateUser handles the creation of a user +// +// @Summary Create user route +// @Description Create a user +// @Tags User +// @Accept json +// @Produce json +// @Param payload body request.CreateUserPayload true "Create user payload" +// @Success 201 {object} domain.User +// @Error 400 {object} response.ErrorResponse "failed to decode request body" +// @Error 500 {object} response.ErrorResponse "failed to create user" +// @Router /api/v1/user [post] func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) { ctx := r.Context() diff --git a/internal/api/router/router.go b/internal/api/router/router.go index e854d22..6b347cb 100644 --- a/internal/api/router/router.go +++ b/internal/api/router/router.go @@ -30,14 +30,17 @@ func NewMux(cfg Config) http.Handler { protected := middleware.WithJWT(authService) + apiMux := http.NewServeMux() + apiMux.HandleFunc("/", handler.DefaultHandler) + apiMux.HandleFunc("POST /login", authHandler.Login) + + apiMux.Handle("POST /users", protected(userHandler.CreateUser)) + + apiMux.Handle("POST /lists", protected(listHandler.CreateList)) + mux := http.NewServeMux() - mux.HandleFunc("/", handler.DefaultHandler) mux.HandleFunc("GET /health", handler.HealthHandler) - mux.HandleFunc("POST /login", authHandler.Login) - - mux.Handle("POST /users", protected(userHandler.CreateUser)) - - mux.Handle("POST /lists", protected(listHandler.CreateList)) + mux.Handle("/api/v1/", http.StripPrefix("/api/v1", apiMux)) var httpHandler http.Handler = mux httpHandler = middleware.WithMaxBytes(1024 * 64)(httpHandler) -- 2.54.0