Compare commits
	
		
			7 Commits
		
	
	
		
			be7f57d5a1
			...
			addddb152a
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| addddb152a | |||
| 3e09afd4b0 | |||
| c312b4e2c8 | |||
| eee5084821 | |||
| ce3076047a | |||
| 9b6282a101 | |||
| a3c2ade9fb | 
							
								
								
									
										1
									
								
								Makefile
									
									
									
									
									
								
							
							
						
						
									
										1
									
								
								Makefile
									
									
									
									
									
								
							| @ -37,6 +37,7 @@ build: tidy sqlc # build. | |||||||
| 	@go build -v -ldflags "$(GO_LDFLAGS)" -o $(OUTPUT_DIR)/howmuch $(ROOT_DIR)/cmd/howmuch/main.go 2>/dev/null | 	@go build -v -ldflags "$(GO_LDFLAGS)" -o $(OUTPUT_DIR)/howmuch $(ROOT_DIR)/cmd/howmuch/main.go 2>/dev/null | ||||||
|  |  | ||||||
| .PHONY: sqlc | .PHONY: sqlc | ||||||
|  | sqlc: | ||||||
| 	@sqlc generate | 	@sqlc generate | ||||||
|  |  | ||||||
| .PHONY: format | .PHONY: format | ||||||
|  | |||||||
							
								
								
									
										54
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										54
									
								
								README.md
									
									
									
									
									
								
							| @ -102,10 +102,10 @@ The execution of the program is then just a command like `howmuch run`. | |||||||
| Moreover, in a distributed system, configs can be stored on `etcd`. | Moreover, in a distributed system, configs can be stored on `etcd`. | ||||||
|  |  | ||||||
| > [Kubernetes stores configuration data into etcd for service discovery and | > [Kubernetes stores configuration data into etcd for service discovery and | ||||||
| cluster management; etcd’s consistency is crucial for correctly scheduling | > cluster management; etcd’s consistency is crucial for correctly scheduling | ||||||
| and operating services. The Kubernetes API server persists cluster state | > and operating services. The Kubernetes API server persists cluster state | ||||||
| into etcd. It uses etcd’s watch API to monitor the cluster and roll out | > into etcd. It uses etcd’s watch API to monitor the cluster and roll out | ||||||
| critical configuration changes.](https://etcd.io/docs/v3.5/learning/why/) | > critical configuration changes.](https://etcd.io/docs/v3.5/learning/why/) | ||||||
|  |  | ||||||
| #### Business logic | #### Business logic | ||||||
|  |  | ||||||
| @ -113,8 +113,8 @@ critical configuration changes.](https://etcd.io/docs/v3.5/learning/why/) | |||||||
| - init DBs (Redis, SQL, Kafka, etc.) | - init DBs (Redis, SQL, Kafka, etc.) | ||||||
| - init web service (http, https, gRPC, etc.) | - init web service (http, https, gRPC, etc.) | ||||||
| - start async tasks like `watch kube-apiserver`; pull data from third-party | - start async tasks like `watch kube-apiserver`; pull data from third-party | ||||||
| services; store, register `/metrics` and listen on some port; start kafka |   services; store, register `/metrics` and listen on some port; start kafka | ||||||
| consumer queue, etc. |   consumer queue, etc. | ||||||
| - Run specific business logic | - Run specific business logic | ||||||
| - Stop the program | - Stop the program | ||||||
| - others... | - others... | ||||||
| @ -166,26 +166,26 @@ that has several layers: | |||||||
| - Entities: the models of the product | - Entities: the models of the product | ||||||
| - Use cases: the core business rule | - Use cases: the core business rule | ||||||
| - Interface Adapters: convert data-in to entities and convert data-out to | - Interface Adapters: convert data-in to entities and convert data-out to | ||||||
| output ports. |   output ports. | ||||||
| - Frameworks and drivers: Web server, DB. | - Frameworks and drivers: Web server, DB. | ||||||
|  |  | ||||||
| Based on this logic, we create the following directories: | Based on this logic, we create the following directories: | ||||||
|  |  | ||||||
| - `model`: entities | - `model`: entities | ||||||
| - `infra`: Provides the necessary functions to setup the infrastructure, | - `infra`: Provides the necessary functions to setup the infrastructure, | ||||||
| especially the DB (output-port), but also the router (input-port). Once |   especially the DB (output-port), but also the router (input-port). Once | ||||||
| setup, we don't touch them anymore. |   setup, we don't touch them anymore. | ||||||
| - `registry`: Provides a register function for the main to register a service. | - `registry`: Provides a register function for the main to register a service. | ||||||
| It takes the pass to the output-port (ex.DBs) and gives back a pass |   It takes the pass to the output-port (ex.DBs) and gives back a pass | ||||||
| (controller) to the input-port |   (controller) to the input-port | ||||||
| - `adapter`: Controllers are one of the adapters, when they are called, | - `adapter`: Controllers are one of the adapters, when they are called, | ||||||
| they parse the user input and parse them into models and run the usecase |   they parse the user input and parse them into models and run the usecase | ||||||
| rules. Then they send back the response(input-port). For the output-port |   rules. Then they send back the response(input-port). For the output-port | ||||||
| part, the `repo` is the implementation of interfaces defined in `usecase/repo`. |   part, the `repo` is the implementation of interfaces defined in `usecase/repo`. | ||||||
| - `usecase`: with the input of adapter, do what have to be done, and answer | - `usecase`: with the input of adapter, do what have to be done, and answer | ||||||
| with the result. In the meantime, we may have to store things into DBs. |   with the result. In the meantime, we may have to store things into DBs. | ||||||
| Here we use the Repository model to decouple the implementation of the repo |   Here we use the Repository model to decouple the implementation of the repo | ||||||
| with the interface. Thus in `usecase/repo` we only define interfaces. |   with the interface. Thus in `usecase/repo` we only define interfaces. | ||||||
|  |  | ||||||
| Then it comes the real design for the app. | Then it comes the real design for the app. | ||||||
|  |  | ||||||
| @ -229,7 +229,7 @@ For the test-driven part, | |||||||
|     (success, timeout, etc.). To test, we have to mock the |     (success, timeout, etc.). To test, we have to mock the | ||||||
|     **database connection**. |     **database connection**. | ||||||
| - usecase: This is the core part to test, it's about the core business. | - usecase: This is the core part to test, it's about the core business. | ||||||
| We provide the data input and we check the data output in a fake repository. |   We provide the data input and we check the data output in a fake repository. | ||||||
|  |  | ||||||
| With this design, although it may seem overkill for this little project, fits | With this design, although it may seem overkill for this little project, fits | ||||||
| perfectly well with the TDD method. | perfectly well with the TDD method. | ||||||
| @ -253,7 +253,7 @@ integration test part (the 2nd point). | |||||||
|  |  | ||||||
| I rethought about the whole API design (even though I have only one yet). I | I rethought about the whole API design (even though I have only one yet). I | ||||||
| have created `/signup` and `/login` without thinking too much, but in fact | have created `/signup` and `/login` without thinking too much, but in fact | ||||||
| it is not quite *RESTful*. | it is not quite _RESTful_. | ||||||
|  |  | ||||||
| **REST** is all about resources. While `/signup` and `/login` is quite | **REST** is all about resources. While `/signup` and `/login` is quite | ||||||
| comprehensible, thus service-oriented, they don't follow the REST philosophy, | comprehensible, thus service-oriented, they don't follow the REST philosophy, | ||||||
| @ -292,8 +292,8 @@ choose server-side-rendering with `templ + htmx`, or even `template+vanilla | |||||||
| javascript`. | javascript`. | ||||||
|  |  | ||||||
| I can still write a rather static Go-frontend-server to serve HTMLs and call | I can still write a rather static Go-frontend-server to serve HTMLs and call | ||||||
| my Go backend. *And it might be a good idea if they communicate on Go native | my Go backend. _And it might be a good idea if they communicate on Go native | ||||||
| rpc.* It worth a try. | rpc._ It worth a try. | ||||||
|  |  | ||||||
| And I have moved on to `Svelte` which seems very simple by design and the | And I have moved on to `Svelte` which seems very simple by design and the | ||||||
| whole compile thing makes it really charm. But this is mainly a Go project, | whole compile thing makes it really charm. But this is mainly a Go project, | ||||||
| @ -330,3 +330,15 @@ the database model design. | |||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
|  | ### 2024/10/11 | ||||||
|  |  | ||||||
|  | I spent 2 days learning some basic of Vue. Learning Vue takes time. There | ||||||
|  | are a lot of concepts and it needs a lot of practice. Even though I may not | ||||||
|  | need a professional level web page, I don't want to copy one module from this | ||||||
|  | blog and another one from another tutorial. I might just put aside the | ||||||
|  | front-end for now and concentrate on my backend Go app. | ||||||
|  |  | ||||||
|  | For now, I will just test my backend with `curl`. | ||||||
|  |  | ||||||
|  | And today's job is to get the login part done! | ||||||
|  | |||||||
| @ -77,6 +77,7 @@ paths: | |||||||
|     post: |     post: | ||||||
|       tags: |       tags: | ||||||
|         - user |         - user | ||||||
|  |         - session | ||||||
|       description: Create a new session for a user |       description: Create a new session for a user | ||||||
|       requestBody: |       requestBody: | ||||||
|         description: Create session |         description: Create session | ||||||
|  | |||||||
| @ -28,4 +28,6 @@ type AppController struct { | |||||||
| 	User interface{ User } | 	User interface{ User } | ||||||
|  |  | ||||||
| 	Admin interface{ Admin } | 	Admin interface{ Admin } | ||||||
|  |  | ||||||
|  | 	Session interface{ Session } | ||||||
| } | } | ||||||
|  | |||||||
							
								
								
									
										20
									
								
								internal/howmuch/adapter/controller/session.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								internal/howmuch/adapter/controller/session.go
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,20 @@ | |||||||
|  | package controller | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"git.vinchent.xyz/vinchent/howmuch/internal/pkg/log" | ||||||
|  | 	"github.com/gin-gonic/gin" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | type Session interface { | ||||||
|  | 	Create(*gin.Context) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | type SessionController struct{} | ||||||
|  |  | ||||||
|  | func NewSessionController() Session { | ||||||
|  | 	return &SessionController{} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (sc *SessionController) Create(ctx *gin.Context) { | ||||||
|  | 	log.CtxLog(ctx).DebugLog("session create") | ||||||
|  | } | ||||||
| @ -25,3 +25,8 @@ INSERT INTO "user" ( | |||||||
|     email, first_name, last_name, password, created_at, updated_at |     email, first_name, last_name, password, created_at, updated_at | ||||||
| ) VALUES ( $1, $2, $3, $4, $5, $6 ) | ) VALUES ( $1, $2, $3, $4, $5, $6 ) | ||||||
| RETURNING *; | RETURNING *; | ||||||
|  |  | ||||||
|  | -- name: GetUserByEmail :one | ||||||
|  | SELECT id, email, first_name, last_name, password, created_at, updated_at | ||||||
|  |     FROM "user" | ||||||
|  |     WHERE email = $1; | ||||||
|  | |||||||
| @ -11,6 +11,27 @@ import ( | |||||||
| 	"github.com/jackc/pgx/v5/pgtype" | 	"github.com/jackc/pgx/v5/pgtype" | ||||||
| ) | ) | ||||||
|  |  | ||||||
|  | const getUserByEmail = `-- name: GetUserByEmail :one | ||||||
|  | SELECT id, email, first_name, last_name, password, created_at, updated_at | ||||||
|  |     FROM "user" | ||||||
|  |     WHERE email = $1 | ||||||
|  | ` | ||||||
|  |  | ||||||
|  | func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error) { | ||||||
|  | 	row := q.db.QueryRow(ctx, getUserByEmail, email) | ||||||
|  | 	var i User | ||||||
|  | 	err := row.Scan( | ||||||
|  | 		&i.ID, | ||||||
|  | 		&i.Email, | ||||||
|  | 		&i.FirstName, | ||||||
|  | 		&i.LastName, | ||||||
|  | 		&i.Password, | ||||||
|  | 		&i.CreatedAt, | ||||||
|  | 		&i.UpdatedAt, | ||||||
|  | 	) | ||||||
|  | 	return i, err | ||||||
|  | } | ||||||
|  |  | ||||||
| const insertUser = `-- name: InsertUser :one | const insertUser = `-- name: InsertUser :one | ||||||
|  |  | ||||||
| INSERT INTO "user" ( | INSERT INTO "user" ( | ||||||
|  | |||||||
| @ -86,3 +86,25 @@ func (ur *userRepository) Create( | |||||||
| 		UpdatedAt: userDB.CreatedAt.Time, | 		UpdatedAt: userDB.CreatedAt.Time, | ||||||
| 	}, nil | 	}, nil | ||||||
| } | } | ||||||
|  |  | ||||||
|  | // GetByEmail if not found, return nil for user but not error. | ||||||
|  | func (ur *userRepository) GetByEmail(ctx context.Context, email string) (*model.User, error) { | ||||||
|  | 	queries := sqlc.New(ur.db) | ||||||
|  | 	userDB, err := queries.GetUserByEmail(ctx, email) | ||||||
|  | 	if errors.Is(err, pgx.ErrNoRows) { | ||||||
|  | 		// No query error, but user not found | ||||||
|  | 		return nil, nil | ||||||
|  | 	} else if err != nil { | ||||||
|  | 		return nil, err | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return &model.User{ | ||||||
|  | 		ID:        int(userDB.ID), | ||||||
|  | 		Email:     userDB.Email, | ||||||
|  | 		FirstName: userDB.FirstName, | ||||||
|  | 		LastName:  userDB.LastName, | ||||||
|  | 		Password:  userDB.Password, | ||||||
|  | 		CreatedAt: userDB.CreatedAt.Time, | ||||||
|  | 		UpdatedAt: userDB.CreatedAt.Time, | ||||||
|  | 	}, nil | ||||||
|  | } | ||||||
|  | |||||||
| @ -54,6 +54,8 @@ func Routes(engine *gin.Engine, c controller.AppController) *gin.Engine { | |||||||
| 		{ | 		{ | ||||||
| 			userV1.POST("/create", func(ctx *gin.Context) { c.User.Create(ctx) }) | 			userV1.POST("/create", func(ctx *gin.Context) { c.User.Create(ctx) }) | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
|  | 		v1.POST("/session/create", func(ctx *gin.Context) { c.Session.Create(ctx) }) | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	return engine | 	return engine | ||||||
|  | |||||||
| @ -53,5 +53,6 @@ func (r *registry) NewAppController() controller.AppController { | |||||||
| 	return controller.AppController{ | 	return controller.AppController{ | ||||||
| 		User:    r.NewUserController(), | 		User:    r.NewUserController(), | ||||||
| 		Admin:   r.NewAdminController(), | 		Admin:   r.NewAdminController(), | ||||||
|  | 		Session: r.NewSessionController(), | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  | |||||||
							
								
								
									
										9
									
								
								internal/howmuch/registry/session.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								internal/howmuch/registry/session.go
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,9 @@ | |||||||
|  | package registry | ||||||
|  |  | ||||||
|  | import "git.vinchent.xyz/vinchent/howmuch/internal/howmuch/adapter/controller" | ||||||
|  |  | ||||||
|  | // NewSessionController returns a session controller's implementation | ||||||
|  | func (r *registry) NewSessionController() controller.Session { | ||||||
|  | 	// u := usecase.NewSessionUsecase(repo.NewSessionRepository(r.db), repo.NewDBRepository(r.db)) | ||||||
|  | 	return controller.NewSessionController() | ||||||
|  | } | ||||||
| @ -30,4 +30,5 @@ import ( | |||||||
|  |  | ||||||
| type UserRepository interface { | type UserRepository interface { | ||||||
| 	Create(ctx context.Context, transaction interface{}, u *model.User) (*model.User, error) | 	Create(ctx context.Context, transaction interface{}, u *model.User) (*model.User, error) | ||||||
|  | 	GetByEmail(ctx context.Context, email string) (*model.User, error) | ||||||
| } | } | ||||||
|  | |||||||
| @ -20,7 +20,7 @@ | |||||||
| // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | ||||||
| // SOFTWARE. | // SOFTWARE. | ||||||
| 
 | 
 | ||||||
| package repo | package usecase | ||||||
| 
 | 
 | ||||||
| import "context" | import "context" | ||||||
| 
 | 
 | ||||||
| @ -20,15 +20,18 @@ | |||||||
| // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | ||||||
| // SOFTWARE. | // SOFTWARE. | ||||||
| 
 | 
 | ||||||
| package repo | package usecase | ||||||
| 
 | 
 | ||||||
| import ( | import ( | ||||||
| 	"context" | 	"context" | ||||||
| 
 | 
 | ||||||
| 	"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/model" | 	"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/model" | ||||||
| 	"github.com/pkg/errors" | 	"github.com/pkg/errors" | ||||||
|  | 	"golang.org/x/crypto/bcrypt" | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
|  | var UserTestDummyErr = errors.New("dummy error") | ||||||
|  | 
 | ||||||
| type TestUserRepository struct{} | type TestUserRepository struct{} | ||||||
| 
 | 
 | ||||||
| func (tur *TestUserRepository) Create( | func (tur *TestUserRepository) Create( | ||||||
| @ -46,3 +49,21 @@ func (tur *TestUserRepository) Create( | |||||||
| 
 | 
 | ||||||
| 	return &user, nil | 	return &user, nil | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | func (ur *TestUserRepository) GetByEmail(ctx context.Context, email string) (*model.User, error) { | ||||||
|  | 	hashedPwd, _ := bcrypt.GenerateFromPassword([]byte("strongHashed"), 12) | ||||||
|  | 	switch email { | ||||||
|  | 	case "a@b.c": | ||||||
|  | 		return &model.User{ | ||||||
|  | 			ID:       123, | ||||||
|  | 			Email:    "a@b.c", | ||||||
|  | 			Password: string(hashedPwd), | ||||||
|  | 		}, nil | ||||||
|  | 	case "query@error.com": | ||||||
|  | 		return nil, UserTestDummyErr | ||||||
|  | 	case "inexist@error.com": | ||||||
|  | 		return nil, nil | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	return nil, UserTestDummyErr | ||||||
|  | } | ||||||
| @ -24,6 +24,7 @@ package usecase | |||||||
|  |  | ||||||
| import ( | import ( | ||||||
| 	"context" | 	"context" | ||||||
|  | 	"errors" | ||||||
| 	"fmt" | 	"fmt" | ||||||
| 	"net/http" | 	"net/http" | ||||||
| 	"regexp" | 	"regexp" | ||||||
| @ -35,11 +36,23 @@ import ( | |||||||
| 	"golang.org/x/crypto/bcrypt" | 	"golang.org/x/crypto/bcrypt" | ||||||
| ) | ) | ||||||
|  |  | ||||||
| var UserExisted = &errno.Errno{ | var ( | ||||||
|  | 	UserExisted = &errno.Errno{ | ||||||
| 		HTTP:    http.StatusBadRequest, | 		HTTP:    http.StatusBadRequest, | ||||||
| 		Code:    errno.ErrorCode(errno.FailedOperationCode, "UserExisted"), | 		Code:    errno.ErrorCode(errno.FailedOperationCode, "UserExisted"), | ||||||
| 		Message: "email already existed.", | 		Message: "email already existed.", | ||||||
| } | 	} | ||||||
|  | 	UserNotExist = &errno.Errno{ | ||||||
|  | 		HTTP:    http.StatusBadRequest, | ||||||
|  | 		Code:    errno.ErrorCode(errno.ResourceNotFoundCode, "UserNotExist"), | ||||||
|  | 		Message: "user does not exists.", | ||||||
|  | 	} | ||||||
|  | 	UserWrongPassword = &errno.Errno{ | ||||||
|  | 		HTTP:    http.StatusBadRequest, | ||||||
|  | 		Code:    errno.ErrorCode(errno.AuthFailureCode, "UserWrongPassword"), | ||||||
|  | 		Message: "wrong password.", | ||||||
|  | 	} | ||||||
|  | ) | ||||||
|  |  | ||||||
| type userUsecase struct { | type userUsecase struct { | ||||||
| 	userRepo repo.UserRepository | 	userRepo repo.UserRepository | ||||||
| @ -48,6 +61,7 @@ type userUsecase struct { | |||||||
|  |  | ||||||
| type User interface { | type User interface { | ||||||
| 	Create(ctx context.Context, u *model.User) (*model.User, error) | 	Create(ctx context.Context, u *model.User) (*model.User, error) | ||||||
|  | 	Exist(ctx context.Context, u *model.User) (bool, error) | ||||||
| } | } | ||||||
|  |  | ||||||
| func NewUserUsecase(r repo.UserRepository, d repo.DBRepository) User { | func NewUserUsecase(r repo.UserRepository, d repo.DBRepository) User { | ||||||
| @ -98,3 +112,24 @@ func (uuc *userUsecase) Create(ctx context.Context, u *model.User) (*model.User, | |||||||
|  |  | ||||||
| 	return user, nil | 	return user, nil | ||||||
| } | } | ||||||
|  |  | ||||||
|  | func (uuc *userUsecase) Exist(ctx context.Context, u *model.User) (bool, error) { | ||||||
|  | 	got, err := uuc.userRepo.GetByEmail(ctx, u.Email) | ||||||
|  | 	// Any query error? | ||||||
|  | 	if err != nil { | ||||||
|  | 		return false, err | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	// User exists? | ||||||
|  | 	if got == nil { | ||||||
|  | 		return false, UserNotExist | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	// Password correct? | ||||||
|  | 	err = bcrypt.CompareHashAndPassword([]byte(got.Password), []byte(u.Password)) | ||||||
|  | 	if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) { | ||||||
|  | 		return false, UserWrongPassword | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return true, nil | ||||||
|  | } | ||||||
|  | |||||||
| @ -27,14 +27,13 @@ import ( | |||||||
| 	"testing" | 	"testing" | ||||||
|  |  | ||||||
| 	"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/model" | 	"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/model" | ||||||
| 	"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/usecase/repo" |  | ||||||
| 	"github.com/stretchr/testify/assert" | 	"github.com/stretchr/testify/assert" | ||||||
| ) | ) | ||||||
|  |  | ||||||
| func TestCreateUser(t *testing.T) { | func TestCreateUser(t *testing.T) { | ||||||
| 	t.Run("normal create", func(t *testing.T) { | 	t.Run("normal create", func(t *testing.T) { | ||||||
| 		ctx := context.Background() | 		ctx := context.Background() | ||||||
| 		userUsecase := NewUserUsecase(&repo.TestUserRepository{}, &repo.TestDBRepository{}) | 		userUsecase := NewUserUsecase(&TestUserRepository{}, &TestDBRepository{}) | ||||||
| 		input := &model.User{ | 		input := &model.User{ | ||||||
| 			Email:     "a@b.c", | 			Email:     "a@b.c", | ||||||
| 			FirstName: "James", | 			FirstName: "James", | ||||||
| @ -51,7 +50,7 @@ func TestCreateUser(t *testing.T) { | |||||||
|  |  | ||||||
| 	t.Run("duplicate create", func(t *testing.T) { | 	t.Run("duplicate create", func(t *testing.T) { | ||||||
| 		ctx := context.Background() | 		ctx := context.Background() | ||||||
| 		userUsecase := NewUserUsecase(&repo.TestUserRepository{}, &repo.TestDBRepository{}) | 		userUsecase := NewUserUsecase(&TestUserRepository{}, &TestDBRepository{}) | ||||||
| 		input := &model.User{ | 		input := &model.User{ | ||||||
| 			Email:     "duplicate@error.com", | 			Email:     "duplicate@error.com", | ||||||
| 			FirstName: "James", | 			FirstName: "James", | ||||||
| @ -63,3 +62,38 @@ func TestCreateUser(t *testing.T) { | |||||||
| 		assert.EqualError(t, err, UserExisted.Error()) | 		assert.EqualError(t, err, UserExisted.Error()) | ||||||
| 	}) | 	}) | ||||||
| } | } | ||||||
|  |  | ||||||
|  | func TestUserExist(t *testing.T) { | ||||||
|  | 	testCases := []struct { | ||||||
|  | 		Name   string | ||||||
|  | 		User   *model.User | ||||||
|  | 		ExpErr error | ||||||
|  | 		ExpRes bool | ||||||
|  | 	}{ | ||||||
|  | 		{"user exists", &model.User{ | ||||||
|  | 			Email:    "a@b.c", | ||||||
|  | 			Password: "strongHashed", | ||||||
|  | 		}, nil, true}, | ||||||
|  | 		{"query error", &model.User{ | ||||||
|  | 			Email:    "query@error.com", | ||||||
|  | 			Password: "strongHashed", | ||||||
|  | 		}, UserTestDummyErr, false}, | ||||||
|  | 		{"user doesn not exist", &model.User{ | ||||||
|  | 			Email:    "inexist@error.com", | ||||||
|  | 			Password: "strongHashed", | ||||||
|  | 		}, UserNotExist, false}, | ||||||
|  | 		{"wrong password", &model.User{ | ||||||
|  | 			Email:    "a@b.c", | ||||||
|  | 			Password: "wrongHashed", | ||||||
|  | 		}, UserWrongPassword, false}, | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	for _, tst := range testCases { | ||||||
|  | 		ctx := context.Background() | ||||||
|  | 		userUsecase := NewUserUsecase(&TestUserRepository{}, &TestDBRepository{}) | ||||||
|  |  | ||||||
|  | 		got, err := userUsecase.Exist(ctx, tst.User) | ||||||
|  | 		assert.ErrorIs(t, err, tst.ExpErr) | ||||||
|  | 		assert.Equal(t, tst.ExpRes, got) | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | |||||||
| @ -33,6 +33,7 @@ type Context interface { | |||||||
|  |  | ||||||
| 	// Request | 	// Request | ||||||
| 	Bind(obj any) error | 	Bind(obj any) error | ||||||
|  | 	GetHeader(key string) string | ||||||
|  |  | ||||||
| 	// Response | 	// Response | ||||||
| 	JSON(code int, obj any) | 	JSON(code int, obj any) | ||||||
|  | |||||||
| @ -26,6 +26,8 @@ import ( | |||||||
| 	"os" | 	"os" | ||||||
| 	"sync" | 	"sync" | ||||||
|  |  | ||||||
|  | 	"git.vinchent.xyz/vinchent/howmuch/internal/pkg/core" | ||||||
|  | 	"git.vinchent.xyz/vinchent/howmuch/internal/pkg/middleware" | ||||||
| 	"go.uber.org/zap" | 	"go.uber.org/zap" | ||||||
| 	"go.uber.org/zap/zapcore" | 	"go.uber.org/zap/zapcore" | ||||||
| ) | ) | ||||||
| @ -100,6 +102,26 @@ func NewLogger(opts *Options) *zapLogger { | |||||||
| 	return &zapLogger{z: z} | 	return &zapLogger{z: z} | ||||||
| } | } | ||||||
|  |  | ||||||
|  | // CtxLog writes context's information into the log | ||||||
|  | func CtxLog(ctx core.Context) *zapLogger { | ||||||
|  | 	return std.CtxLog(ctx) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (z *zapLogger) CtxLog(ctx core.Context) *zapLogger { | ||||||
|  | 	zz := z.clone() | ||||||
|  |  | ||||||
|  | 	if rid := ctx.GetHeader(middleware.XRequestID); rid != "" { | ||||||
|  | 		zz.z = zz.z.With(zap.Any(middleware.XRequestID, rid)) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return zz | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (z *zapLogger) clone() *zapLogger { | ||||||
|  | 	zz := *z | ||||||
|  | 	return &zz | ||||||
|  | } | ||||||
|  |  | ||||||
| func (z *zapLogger) FatalLog(msg string, keyValues ...interface{}) { | func (z *zapLogger) FatalLog(msg string, keyValues ...interface{}) { | ||||||
| 	z.z.Sugar().Fatalw(msg, keyValues...) | 	z.z.Sugar().Fatalw(msg, keyValues...) | ||||||
| } | } | ||||||
|  | |||||||
| @ -27,20 +27,20 @@ import ( | |||||||
| 	"github.com/google/uuid" | 	"github.com/google/uuid" | ||||||
| ) | ) | ||||||
|  |  | ||||||
| const requestID = "X-Request-Id" | const XRequestID = "X-Request-Id" | ||||||
|  |  | ||||||
| func RequestID() gin.HandlerFunc { | func RequestID() gin.HandlerFunc { | ||||||
| 	return func(ctx *gin.Context) { | 	return func(ctx *gin.Context) { | ||||||
| 		var rid string | 		var rid string | ||||||
|  |  | ||||||
| 		if rid = ctx.GetHeader(requestID); rid != "" { | 		if rid = ctx.GetHeader(XRequestID); rid != "" { | ||||||
| 			ctx.Request.Header.Add(requestID, rid) | 			ctx.Request.Header.Add(XRequestID, rid) | ||||||
| 			ctx.Next() | 			ctx.Next() | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		rid = uuid.NewString() | 		rid = uuid.NewString() | ||||||
| 		ctx.Request.Header.Add(requestID, rid) | 		ctx.Request.Header.Add(XRequestID, rid) | ||||||
| 		ctx.Header(requestID, rid) | 		ctx.Header(XRequestID, rid) | ||||||
| 		ctx.Next() | 		ctx.Next() | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  | |||||||
| @ -24,33 +24,14 @@ package middleware | |||||||
|  |  | ||||||
| import ( | import ( | ||||||
| 	"net/http" | 	"net/http" | ||||||
| 	"net/http/httptest" |  | ||||||
| 	"testing" | 	"testing" | ||||||
|  |  | ||||||
|  | 	"git.vinchent.xyz/vinchent/howmuch/internal/pkg/test" | ||||||
| 	"github.com/gin-gonic/gin" | 	"github.com/gin-gonic/gin" | ||||||
| 	"github.com/google/uuid" | 	"github.com/google/uuid" | ||||||
| 	"github.com/stretchr/testify/assert" | 	"github.com/stretchr/testify/assert" | ||||||
| ) | ) | ||||||
|  |  | ||||||
| type header struct { |  | ||||||
| 	Key   string |  | ||||||
| 	Value string |  | ||||||
| } |  | ||||||
|  |  | ||||||
| func performRequest( |  | ||||||
| 	r http.Handler, |  | ||||||
| 	method, path string, |  | ||||||
| 	headers ...header, |  | ||||||
| ) *httptest.ResponseRecorder { |  | ||||||
| 	req := httptest.NewRequest(method, path, nil) |  | ||||||
| 	for _, h := range headers { |  | ||||||
| 		req.Header.Add(h.Key, h.Value) |  | ||||||
| 	} |  | ||||||
| 	w := httptest.NewRecorder() |  | ||||||
| 	r.ServeHTTP(w, req) |  | ||||||
| 	return w |  | ||||||
| } |  | ||||||
|  |  | ||||||
| func TestRequestID(t *testing.T) { | func TestRequestID(t *testing.T) { | ||||||
| 	r := gin.New() | 	r := gin.New() | ||||||
| 	r.Use(RequestID()) | 	r.Use(RequestID()) | ||||||
| @ -58,21 +39,28 @@ func TestRequestID(t *testing.T) { | |||||||
| 	wanted := "123" | 	wanted := "123" | ||||||
|  |  | ||||||
| 	r.GET("/example", func(c *gin.Context) { | 	r.GET("/example", func(c *gin.Context) { | ||||||
| 		got = c.GetHeader(requestID) | 		got = c.GetHeader(XRequestID) | ||||||
| 		c.Status(http.StatusOK) | 		c.Status(http.StatusOK) | ||||||
| 	}) | 	}) | ||||||
|  |  | ||||||
| 	r.POST("/example", func(c *gin.Context) { | 	r.POST("/example", func(c *gin.Context) { | ||||||
| 		got = c.GetHeader(requestID) | 		got = c.GetHeader(XRequestID) | ||||||
| 		c.String(http.StatusAccepted, "ok") | 		c.String(http.StatusAccepted, "ok") | ||||||
| 	}) | 	}) | ||||||
|  |  | ||||||
| 	// Test with Request ID | 	// Test with Request ID | ||||||
| 	_ = performRequest(r, "GET", "/example?a=100", header{requestID, wanted}) | 	_ = test.PerformRequest( | ||||||
|  | 		t, | ||||||
|  | 		r, | ||||||
|  | 		"GET", | ||||||
|  | 		"/example?a=100", | ||||||
|  | 		nil, | ||||||
|  | 		test.Header{Key: XRequestID, Value: wanted}, | ||||||
|  | 	) | ||||||
| 	assert.Equal(t, "123", got) | 	assert.Equal(t, "123", got) | ||||||
|  |  | ||||||
| 	res := performRequest(r, "GET", "/example?a=100") | 	res := test.PerformRequest(t, r, "GET", "/example?a=100", nil) | ||||||
| 	assert.NotEqual(t, "", got) | 	assert.NotEqual(t, "", got) | ||||||
| 	assert.NoError(t, uuid.Validate(got)) | 	assert.NoError(t, uuid.Validate(got)) | ||||||
| 	assert.Equal(t, res.Header()[requestID][0], got) | 	assert.Equal(t, res.Header()[XRequestID][0], got) | ||||||
| } | } | ||||||
|  | |||||||
							
								
								
									
										30
									
								
								internal/pkg/test/request.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								internal/pkg/test/request.go
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,30 @@ | |||||||
|  | package test | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"io" | ||||||
|  | 	"net/http" | ||||||
|  | 	"net/http/httptest" | ||||||
|  | 	"testing" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | type Header struct { | ||||||
|  | 	Key   string | ||||||
|  | 	Value string | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func PerformRequest( | ||||||
|  | 	t testing.TB, | ||||||
|  | 	r http.Handler, | ||||||
|  | 	method, path string, | ||||||
|  | 	body io.Reader, | ||||||
|  | 	headers ...Header, | ||||||
|  | ) *httptest.ResponseRecorder { | ||||||
|  | 	t.Helper() | ||||||
|  | 	req := httptest.NewRequest(method, path, body) | ||||||
|  | 	for _, h := range headers { | ||||||
|  | 		req.Header.Add(h.Key, h.Value) | ||||||
|  | 	} | ||||||
|  | 	w := httptest.NewRecorder() | ||||||
|  | 	r.ServeHTTP(w, req) | ||||||
|  | 	return w | ||||||
|  | } | ||||||
		Reference in New Issue
	
	Block a user
	