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
|
||||||
|
62
README.md
62
README.md
@ -54,11 +54,11 @@ Next I need to design the API.
|
|||||||
- add other users to that event
|
- add other users to that event
|
||||||
- A user can only view their own events, but not the events of other users'
|
- A user can only view their own events, but not the events of other users'
|
||||||
- A user can add an expense to the event (reason, date, who payed how much,
|
- A user can add an expense to the event (reason, date, who payed how much,
|
||||||
who benefited how much)
|
who benefited how much)
|
||||||
- Users in the event can edit or delete one entry
|
- Users in the event can edit or delete one entry
|
||||||
- changes are sent to friends in the event
|
- changes are sent to friends in the event
|
||||||
- User can get the money they spent themselves and the money they must pay
|
- User can get the money they spent themselves and the money they must pay
|
||||||
to each other
|
to each other
|
||||||
- User can also get the total amount or the histories.
|
- User can also get the total amount or the histories.
|
||||||
|
|
||||||
That is what I thought of for now.
|
That is what I thought of for now.
|
||||||
@ -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.
|
||||||
|
|
||||||
@ -219,17 +219,17 @@ For the test-driven part,
|
|||||||
- infra: routes and db connections, it works when it works. Nothing to test.
|
- infra: routes and db connections, it works when it works. Nothing to test.
|
||||||
- registry: Just return some structs, no logic. **Not worth testing**
|
- registry: Just return some structs, no logic. **Not worth testing**
|
||||||
- adapter:
|
- adapter:
|
||||||
- input-port (controller) test: it is about testing parsing the input
|
- input-port (controller) test: it is about testing parsing the input
|
||||||
value, and the output results writing. The unit test of controller is to
|
value, and the output results writing. The unit test of controller is to
|
||||||
**make sure that they behave as defined in the API documentation**. To
|
**make sure that they behave as defined in the API documentation**. To
|
||||||
test, we have to mock the **business service**.
|
test, we have to mock the **business service**.
|
||||||
- output-port (repo) test: it is about testing converting business model
|
- output-port (repo) test: it is about testing converting business model
|
||||||
to database model and the interaction with the database. If we are going
|
to database model and the interaction with the database. If we are going
|
||||||
to test them, it's about simulating different type of database behaviour
|
to test them, it's about simulating different type of database behaviour
|
||||||
(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.
|
|||||||
![Core user story part 1](./docs/howmuch_us1.drawio.png)
|
![Core user story part 1](./docs/howmuch_us1.drawio.png)
|
||||||
|
|
||||||
![Database model](./docs/howmuch.drawio.png)
|
![Database model](./docs/howmuch.drawio.png)
|
||||||
|
|
||||||
|
### 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
|
||||||
|
@ -51,7 +51,8 @@ func NewRegistry(db *pgx.Conn) Registry {
|
|||||||
// each domain.
|
// each domain.
|
||||||
func (r *registry) NewAppController() controller.AppController {
|
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 (
|
||||||
HTTP: http.StatusBadRequest,
|
UserExisted = &errno.Errno{
|
||||||
Code: errno.ErrorCode(errno.FailedOperationCode, "UserExisted"),
|
HTTP: http.StatusBadRequest,
|
||||||
Message: "email already existed.",
|
Code: errno.ErrorCode(errno.FailedOperationCode, "UserExisted"),
|
||||||
}
|
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
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user