diff --git a/.github/workflows/Code_quality_and_docker_build.yml b/.github/workflows/Code_quality_and_docker_build.yml index 6bc0236..9596528 100644 --- a/.github/workflows/Code_quality_and_docker_build.yml +++ b/.github/workflows/Code_quality_and_docker_build.yml @@ -136,6 +136,11 @@ jobs: - name: Create api/.env.api run: | touch api/.env.api + echo "TIME_VALIDATION_MIN=${{ secrets.TIME_VALIDATION_MIN }}" >> api/.env.api + echo "STUDENT_NUMBER_MIN=${{ secrets.STUDENT_NUMBER_MIN }}" >> api/.env.api + echo "STUDENT_NUMBER_MAX=${{ secrets.STUDENT_NUMBER_MAX }}" >> api/.env.api + echo "NAME_MIN_LENGTH=${{ secrets.NAME_MIN_LENGTH }}" >> api/.env.api + echo "NAME_MAX_LENGTH=${{ secrets.NAME_MAX_LENGTH }}" >> api/.env.api echo "MYSQL_USER=${{ secrets.MYSQL_USER }}" >> api/.env.api echo "MYSQL_PASSWORD=${{ secrets.MYSQL_PASSWORD }}" >> api/.env.api echo "MYSQL_HOST=${{ secrets.MYSQL_HOST }}" >> api/.env.api @@ -159,6 +164,7 @@ jobs: echo "NFC_SERVICE_CODE=${{ secrets.NFC_SERVICE_CODE }}" >> nfc_reader/.env.nfc_reader echo "NFC_STUDENT_NUM_BLOCK_CODE=${{ secrets.NFC_STUDENT_NUM_BLOCK_CODE }}" >> nfc_reader/.env.nfc_reader echo "NFC_NAME_BLOCK_CODE=${{ secrets.NFC_NAME_BLOCK_CODE }}" >> nfc_reader/.env.nfc_reader + echo "API_URL=${{ secrets.API_URL }}" >> nfc_reader/.env.nfc_reader - name: Build docker image run: sudo docker-compose build diff --git a/.github/workflows/Run_api_server_tests.yml b/.github/workflows/Run_api_server_tests.yml new file mode 100644 index 0000000..fb79ab3 --- /dev/null +++ b/.github/workflows/Run_api_server_tests.yml @@ -0,0 +1,33 @@ +name: Run api server tests + +on: + push: + branches: + - main + pull_request: + +jobs: + run-api-server-test: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Create api/.env.api + run: | + touch api/.env.api + echo "TIME_VALIDATION_MIN=${{ secrets.TIME_VALIDATION_MIN }}" >> api/.env.api + echo "STUDENT_NUMBER_MIN=${{ secrets.STUDENT_NUMBER_MIN }}" >> api/.env.api + echo "STUDENT_NUMBER_MAX=${{ secrets.STUDENT_NUMBER_MAX }}" >> api/.env.api + echo "NAME_MIN_LENGTH=${{ secrets.NAME_MIN_LENGTH }}" >> api/.env.api + echo "NAME_MAX_LENGTH=${{ secrets.NAME_MAX_LENGTH }}" >> api/.env.api + echo "MYSQL_USER=${{ secrets.MYSQL_USER }}" >> api/.env.api + echo "MYSQL_PASSWORD=${{ secrets.MYSQL_PASSWORD }}" >> api/.env.api + echo "MYSQL_HOST=${{ secrets.MYSQL_HOST }}" >> api/.env.api + echo "MYSQL_DATABASE=${{ secrets.MYSQL_DATABASE }}" >> api/.env.api + + - name: Run Go tests + run: | + cd api/ && sudo docker build --target test -t nfc-entry-management-api-test:latest -f ./Dockerfile . + sudo docker run --env-file .env.api --rm nfc-entry-management-api-test:latest diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2f58082..96df415 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -57,6 +57,15 @@ repos: language: system types: [python] + - repo: local + hooks: + - id: go-tests + name: Run Go tests + entry: sh -c 'cd api/ && docker build --target test -t nfc-entry-management-api-test:latest -f ./Dockerfile . && docker run --env-file .env.api --rm nfc-entry-management-api-test:latest' + language: system + pass_filenames: false + stages: [commit] + - repo: local hooks: - id: docker-compose-check diff --git a/api/.env.api.example b/api/.env.api.example index b1758dd..4d3bdd9 100644 --- a/api/.env.api.example +++ b/api/.env.api.example @@ -1,3 +1,8 @@ +TIME_VALIDATION_MIN=1704034800 +STUDENT_NUMBER_MIN=10000000 +STUDENT_NUMBER_MAX=39999999 +NAME_MIN_LENGTH=2 +NAME_MAX_LENGTH=32 MYSQL_USER=admin MYSQL_PASSWORD=password MYSQL_HOST=mysql diff --git a/api/Dockerfile b/api/Dockerfile index 9fc979b..90a6faf 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -2,9 +2,8 @@ FROM golang:1.21.6-alpine AS base WORKDIR /app -COPY ./src /app/src +COPY ./src . -WORKDIR /app/src RUN apk add --no-cache git && \ go install -v golang.org/x/tools/cmd/goimports@latest && \ @@ -14,18 +13,23 @@ RUN go mod tidy ENV PATH="/app/bin:${PATH}" +# test stage +FROM base AS test + +WORKDIR /app + +CMD [ "go","test","-v","./..." ] + # development stage FROM base AS development WORKDIR /app -COPY ./.golangci.yml . - RUN wget -O- -nv https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b /usr/local/bin v1.55.2 # lint stage FROM development AS lint -WORKDIR /app/src +WORKDIR /app -CMD [ "golangci-lint","run","--config","/app/.golangci.yml" ] +CMD [ "golangci-lint","run","--config",".golangci.yml" ] diff --git a/api/.golangci.yml b/api/src/.golangci.yml similarity index 100% rename from api/.golangci.yml rename to api/src/.golangci.yml diff --git a/api/src/controller/api_controller.go b/api/src/controller/api_controller.go new file mode 100644 index 0000000..48d6c2e --- /dev/null +++ b/api/src/controller/api_controller.go @@ -0,0 +1,84 @@ +package controller + +import ( + "api/model" + "api/usecase" + "fmt" + "net/http" + "time" + + "github.com/labstack/echo" +) + +type IUserAndEntryController interface { + HandleUserAndEntry(c echo.Context) error +} + +type UserAndEntryController struct { + uu usecase.IUserUsecase + eu usecase.IEntryUsecase +} + +type Response struct { + UserMessage string `json:"user_message"` + EntryMessage string `json:"entry_message"` +} + +type Request struct { + StudentNumber uint `json:"student_number"` + Name string `json:"name"` + Timestamp float64 `json:"timestamp"` +} + +func NewUserAndEntryController(uu usecase.IUserUsecase, eu usecase.IEntryUsecase) IUserAndEntryController { + return &UserAndEntryController{uu, eu} +} + +func (ac *UserAndEntryController) HandleUserAndEntry(c echo.Context) error { + request := Request{} + if err := c.Bind(&request); err != nil { + fmt.Println(err.Error()) + return c.JSON(http.StatusBadRequest, err.Error()) + } + + if request.StudentNumber == 0 { + fmt.Println("student number is required") + return c.JSON(http.StatusBadRequest, "student number is required") + } + if request.Name == "" { + return c.JSON(http.StatusBadRequest, "name is required") + } + if request.Timestamp == 0 { + return c.JSON(http.StatusBadRequest, "timestamp is required") + } + + // convert float64 to time.Time + seconds := int64(request.Timestamp) + nanoseconds := int64((request.Timestamp - float64(seconds)) * 1e9) + timestamp := time.Unix(seconds, nanoseconds) + + user := model.User{ + StudentNumber: request.StudentNumber, + Name: request.Name, + CreatedAt: timestamp, + UpdatedAt: timestamp, + } + + userMessage, err := ac.uu.CreateOrUpdateUser(user) + if err != nil { + fmt.Println(err.Error()) + return c.JSON(http.StatusInternalServerError, err.Error()) + } + + entryMessage, err := ac.eu.EntryOrExit(request.StudentNumber, timestamp) + if err != nil { + return c.JSON(http.StatusInternalServerError, err.Error()) + } + + response := Response{ + UserMessage: userMessage, + EntryMessage: entryMessage, + } + + return c.JSON(http.StatusOK, response) +} diff --git a/api/src/controller/api_controller_test.go b/api/src/controller/api_controller_test.go new file mode 100644 index 0000000..1846c2d --- /dev/null +++ b/api/src/controller/api_controller_test.go @@ -0,0 +1,299 @@ +package controller_test + +import ( + "api/controller" + "api/model" + "api/repository" + "api/usecase" + "api/validator" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/labstack/echo" + "github.com/stretchr/testify/assert" + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +func NewDBMock() (*gorm.DB, sqlmock.Sqlmock, error) { + db, mock, err := sqlmock.New() + if err != nil { + return nil, nil, err + } + + gormDB, err := gorm.Open(mysql.Dialector{Config: &mysql.Config{DriverName: "mysql", Conn: db, SkipInitializeWithVersion: true}}, &gorm.Config{}) + if err != nil { + return nil, nil, err + } + + return gormDB, mock, nil +} + +func TestApiController_RootController(t *testing.T) { + + gormDB, mock, err := NewDBMock() + if err != nil { + t.Errorf(err.Error()) + } + + c := controller.IUserAndEntryController( + controller.NewUserAndEntryController( + usecase.IUserUsecase( + usecase.NewUserUsecase( + repository.IUserRepository( + repository.NewUserRepository(gormDB), + ), + validator.IUserValidator( + validator.NewUserValidator(), + ), + ), + ), + usecase.IEntryUsecase( + usecase.NewEntryUsecase( + repository.IEntryRepository( + repository.NewEntryRepository(gormDB), + ), + validator.IEntryValidator( + validator.NewEntryValidator(), + ), + ), + ), + ), + ) + + t.Run("valid", func(t *testing.T) { + + request := controller.Request{ + StudentNumber: 20122027, + Name: "カイシ タロウ", + Timestamp: float64(time.Now().UnixNano()) / 1e9, + } + + // convert float64 to time.Time + seconds := int64(request.Timestamp) + nanoseconds := int64((request.Timestamp - float64(seconds)) * 1e9) + requestTimestamp := time.Unix(seconds, nanoseconds) + + //GetUserByStudentNumber + storedUser := model.User{} + userRows := sqlmock.NewRows([]string{"name", "created_at", "updated_at", "student_number"}). + AddRow(storedUser.Name, storedUser.CreatedAt, storedUser.UpdatedAt, storedUser.StudentNumber) + mock.ExpectQuery("^SELECT \\* FROM `users` WHERE student_number=\\? ORDER BY `users`.`student_number` LIMIT 1$"). + WithArgs(request.StudentNumber).WillReturnRows(userRows) + + //CreateUser + mock.ExpectBegin() + mock.ExpectExec("INSERT INTO `users` \\(`name`,`created_at`,`updated_at`,`student_number`\\) VALUES \\(\\?,\\?,\\?,\\?\\)"). + WithArgs(request.Name, requestTimestamp, requestTimestamp, request.StudentNumber). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectCommit() + + //GetStudentNumberWithNullExitTime + initEntry := model.Entry{} + entryRows := sqlmock.NewRows([]string{"id", "entry_time", "exit_time", "student_number"}). + AddRow(initEntry.ID, initEntry.EntryTime, initEntry.ExitTime, initEntry.StudentNumber) + mock.ExpectQuery("SELECT \\* FROM `entries` WHERE student_number=\\? AND exit_time IS NULL ORDER BY `entries`.`id` LIMIT 1"). + WithArgs(request.StudentNumber). + WillReturnRows(entryRows) + + //CreateEntry + mock.ExpectBegin() + mock.ExpectExec("INSERT INTO `entries` \\(`entry_time`,`exit_time`,`student_number`\\) VALUES \\(\\?,\\?,\\?\\)"). + WithArgs(requestTimestamp, nil, request.StudentNumber). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectCommit() + + //request to json + requestJson, err := json.Marshal(request) + if err != nil { + t.Errorf(err.Error()) + } + + //create echo context + echoServer := echo.New() + req := httptest.NewRequest( + http.MethodPost, + "/", + strings.NewReader(string(requestJson)), + ) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + echoContext := echoServer.NewContext(req, rec) + echoContext.SetPath("/") + + if err := c.HandleUserAndEntry(echoContext); err != nil { + t.Errorf(err.Error()) + } + + if rec.Code != http.StatusOK { + t.Errorf(err.Error()) + } + }) + + t.Run("empty_body", func(t *testing.T) { + //create echo context + echoServer := echo.New() + req := httptest.NewRequest( + http.MethodPost, + "/", + strings.NewReader(""), + ) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + echoContext := echoServer.NewContext(req, rec) + echoContext.SetPath("/") + + err = c.HandleUserAndEntry(echoContext) + if err != nil { + t.Errorf(err.Error()) + } + + expectedStatus := http.StatusBadRequest + assert.Equal(t, expectedStatus, rec.Code) + + expectedBody := "\"code=400, message=Request body can't be empty\"\n" + assert.Equal(t, expectedBody, rec.Body.String()) + }) + + t.Run("invalid_json", func(t *testing.T) { + //create echo context + echoServer := echo.New() + req := httptest.NewRequest( + http.MethodPost, + "/", + strings.NewReader("{invalid json}"), + ) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + echoContext := echoServer.NewContext(req, rec) + echoContext.SetPath("/") + + err = c.HandleUserAndEntry(echoContext) + if err != nil { + t.Errorf(err.Error()) + } + + expectedStatus := http.StatusBadRequest + assert.Equal(t, expectedStatus, rec.Code) + + expectedBody := "\"code=400, message=Syntax error: offset=2, error=invalid character 'i' looking for beginning of object key string\"\n" + assert.Equal(t, expectedBody, rec.Body.String()) + }) + + t.Run("not_required_studentNumber", func(t *testing.T) { + + request := controller.Request{ + Name: "カイシ タロウ", + Timestamp: float64(time.Now().UnixNano()) / 1e9, + } + + //request to json + requestJson, err := json.Marshal(request) + if err != nil { + t.Errorf(err.Error()) + } + + //create echo context + echoServer := echo.New() + req := httptest.NewRequest( + http.MethodPost, + "/", + strings.NewReader(string(requestJson)), + ) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + echoContext := echoServer.NewContext(req, rec) + echoContext.SetPath("/") + + err = c.HandleUserAndEntry(echoContext) + if err != nil { + t.Errorf(err.Error()) + } + + expectedStatus := http.StatusBadRequest + assert.Equal(t, expectedStatus, rec.Code) + + expectedBody := "\"student number is required\"\n" + assert.Equal(t, expectedBody, rec.Body.String()) + + }) + + t.Run("not_required_name", func(t *testing.T) { + + request := controller.Request{ + StudentNumber: 20122027, + Timestamp: float64(time.Now().UnixNano()) / 1e9, + } + + //request to json + requestJson, err := json.Marshal(request) + if err != nil { + t.Errorf(err.Error()) + } + + //create echo context + echoServer := echo.New() + req := httptest.NewRequest( + http.MethodPost, + "/", + strings.NewReader(string(requestJson)), + ) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + echoContext := echoServer.NewContext(req, rec) + echoContext.SetPath("/") + + err = c.HandleUserAndEntry(echoContext) + if err != nil { + t.Errorf(err.Error()) + } + + expectedStatus := http.StatusBadRequest + assert.Equal(t, expectedStatus, rec.Code) + + expectedBody := "\"name is required\"\n" + assert.Equal(t, expectedBody, rec.Body.String()) + }) + + t.Run("not_required_timestamp", func(t *testing.T) { + + request := controller.Request{ + StudentNumber: 20122027, + Name: "カイシ タロウ", + } + + //request to json + requestJson, err := json.Marshal(request) + if err != nil { + t.Errorf(err.Error()) + } + + //create echo context + echoServer := echo.New() + req := httptest.NewRequest( + http.MethodPost, + "/", + strings.NewReader(string(requestJson)), + ) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + echoContext := echoServer.NewContext(req, rec) + echoContext.SetPath("/") + + err = c.HandleUserAndEntry(echoContext) + if err != nil { + t.Errorf(err.Error()) + } + + expectedStatus := http.StatusBadRequest + assert.Equal(t, expectedStatus, rec.Code) + + expectedBody := "\"timestamp is required\"\n" + assert.Equal(t, expectedBody, rec.Body.String()) + }) +} diff --git a/api/src/db/db.go b/api/src/db/db.go index fdd0bbb..78b5262 100644 --- a/api/src/db/db.go +++ b/api/src/db/db.go @@ -1,24 +1,17 @@ package db import ( - "fmt" "log" - "os" "gorm.io/driver/mysql" "gorm.io/gorm" ) -func ConnectDB() *gorm.DB { - - url := fmt.Sprintf("%s:%s@tcp(%s)/%s", os.Getenv("MYSQL_USER"), os.Getenv("MYSQL_PASSWORD"), os.Getenv("MYSQL_HOST"), os.Getenv("MYSQL_DATABASE")) - - db, err := gorm.Open(mysql.Open(url), &gorm.Config{}) +func ConnectDB(config mysql.Config) *gorm.DB { + db, err := gorm.Open(mysql.New(config), &gorm.Config{}) if err != nil { log.Fatalln(err) } - fmt.Printf("Connected") - return db } @@ -27,5 +20,9 @@ func CloseDB(db *gorm.DB) { if err != nil { log.Fatalln(err) } - dbSQL.Close() + + err = dbSQL.Close() + if err != nil { + log.Println(err) + } } diff --git a/api/src/db/db_test.go b/api/src/db/db_test.go new file mode 100644 index 0000000..1c65f06 --- /dev/null +++ b/api/src/db/db_test.go @@ -0,0 +1,72 @@ +package db_test + +import ( + "api/db" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +func NewDBMock() (*gorm.DB, sqlmock.Sqlmock, error) { + db, mock, err := sqlmock.New() + if err != nil { + return nil, nil, err + } + + gormDB, err := gorm.Open( + mysql.Dialector{ + Config: &mysql.Config{ + Conn: db, + SkipInitializeWithVersion: true, + }, + }, + &gorm.Config{}, + ) + if err != nil { + return nil, nil, err + } + + return gormDB, mock, nil +} + +func TestConnectDB(t *testing.T) { + mockdb, mock, err := sqlmock.New() + if err != nil { + t.Errorf(err.Error()) + } + + mock.ExpectQuery("SELECT VERSION()"). + WillReturnRows(sqlmock.NewRows([]string{"VERSION()"}). + AddRow("8.0.36")) + + mysqlConfig := mysql.Config{ + DriverName: "mysql", + Conn: mockdb, + } + + gormDB := db.ConnectDB(mysqlConfig) + + if gormDB == nil { + t.Errorf("failed to connect to database") + } + + if _, err := gormDB.DB(); err != nil { + t.Errorf(err.Error()) + } +} + +func TestCloseDB(t *testing.T) { + gormDB, mock, err := NewDBMock() + if err != nil { + t.Errorf(err.Error()) + } + + mock.ExpectClose() + db.CloseDB(gormDB) + + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf(err.Error()) + } +} diff --git a/api/src/go.mod b/api/src/go.mod index 4ee9540..e654c6a 100644 --- a/api/src/go.mod +++ b/api/src/go.mod @@ -3,22 +3,28 @@ module api go 1.21.6 require ( + github.com/DATA-DOG/go-sqlmock v1.5.2 + github.com/go-ozzo/ozzo-validation/v4 v4.3.0 github.com/labstack/echo v3.3.10+incompatible + github.com/stretchr/testify v1.8.4 gorm.io/driver/mysql v1.5.2 gorm.io/gorm v1.25.6 ) require ( + github.com/davecgh/go-spew v1.1.1 // indirect github.com/go-sql-driver/mysql v1.7.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/labstack/gommon v0.4.2 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect golang.org/x/crypto v0.18.0 // indirect golang.org/x/net v0.10.0 // indirect golang.org/x/sys v0.16.0 // indirect golang.org/x/text v0.14.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/api/src/go.sum b/api/src/go.sum index 2ab6065..3b9ec7d 100644 --- a/api/src/go.sum +++ b/api/src/go.sum @@ -1,13 +1,19 @@ +github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= +github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= +github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496 h1:zV3ejI06GQ59hwDQAvmK1qxOQGB3WuVTRoY0okPTAv0= +github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-ozzo/ozzo-validation/v4 v4.3.0 h1:byhDUpfEwjsVQb1vBunvIjh2BHQ9ead57VkAEY4V+Es= +github.com/go-ozzo/ozzo-validation/v4 v4.3.0/go.mod h1:2NKgrcHl3z6cJs+3Oo940FPRiTzuqKbvfrL2RxCj6Ew= github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc= github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= -github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= -github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= github.com/labstack/echo v3.3.10+incompatible h1:pGRcYk231ExFAyoAjAfD85kQzRJCRI8bbnE7CX5OEgg= github.com/labstack/echo v3.3.10+incompatible/go.mod h1:0INS7j/VjnFxD4E2wkz67b8cVwCLbBmJyDaka6Cmk1s= github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= @@ -19,6 +25,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= @@ -35,6 +43,9 @@ golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/driver/mysql v1.5.2 h1:QC2HRskSE75wBuOxe0+iCkyJZ+RqpudsQtqkp+IMuXs= diff --git a/api/src/main.go b/api/src/main.go index 32d6f68..d73c75b 100644 --- a/api/src/main.go +++ b/api/src/main.go @@ -1,21 +1,34 @@ package main import ( - "net/http" + "api/controller" + "api/db" + "api/repository" + "api/router" + "api/usecase" + "api/validator" + "fmt" + "os" - "github.com/labstack/echo" + "gorm.io/driver/mysql" ) func main() { - e := echo.New() - - e.GET("/", hello) + url := fmt.Sprintf("%s:%s@tcp(%s)/%s?parseTime=true", os.Getenv("MYSQL_USER"), os.Getenv("MYSQL_PASSWORD"), os.Getenv("MYSQL_HOST"), os.Getenv("MYSQL_DATABASE")) + mysqlConfig := mysql.Config{ + DriverName: "mysql", + DSN: url, + SkipInitializeWithVersion: true, + } + db := db.ConnectDB(mysqlConfig) + entryValidator := validator.NewEntryValidator() + userValidator := validator.NewUserValidator() + userRepository := repository.NewUserRepository(db) + entryRepository := repository.NewEntryRepository(db) + entryUsecase := usecase.NewEntryUsecase(entryRepository, entryValidator) + userUsecase := usecase.NewUserUsecase(userRepository, userValidator) + UserAndEntryController := controller.NewUserAndEntryController(userUsecase, entryUsecase) + e := router.NewRouter(UserAndEntryController) e.Logger.Fatal(e.Start(":8080")) } - -func hello(c echo.Context) error { - return c.JSON(http.StatusOK, map[string]string{ - "message": "Hello World", - }) -} diff --git a/api/src/migrate/migrate.go b/api/src/migrate/migrate.go index 2da39ac..e368a82 100644 --- a/api/src/migrate/migrate.go +++ b/api/src/migrate/migrate.go @@ -4,10 +4,18 @@ import ( "api/db" "api/model" "fmt" + "os" + + "gorm.io/driver/mysql" ) func main() { - dbConn := db.ConnectDB() + mysqlConfig := mysql.Config{ + DriverName: "mysql", + DSN: fmt.Sprintf("%s:%s@tcp(%s)/%s?parseTime=true", os.Getenv("MYSQL_USER"), os.Getenv("MYSQL_PASSWORD"), os.Getenv("MYSQL_HOST"), os.Getenv("MYSQL_DATABASE")), + } + + dbConn := db.ConnectDB(mysqlConfig) defer fmt.Println("Successfully Migrated") defer db.CloseDB(dbConn) err := dbConn.AutoMigrate(&model.User{}, &model.Entry{}) diff --git a/api/src/migrate/migrate_test.go b/api/src/migrate/migrate_test.go new file mode 100644 index 0000000..014a09a --- /dev/null +++ b/api/src/migrate/migrate_test.go @@ -0,0 +1,7 @@ +package main_test + +import "testing" + +func TestMigrate_Nooop(t *testing.T) { + t.Log("Noop") +} diff --git a/api/src/model/entry.go b/api/src/model/entry.go index d3946d0..6ef87a1 100644 --- a/api/src/model/entry.go +++ b/api/src/model/entry.go @@ -3,8 +3,8 @@ package model import "time" type Entry struct { - ID uint `json:"id" gorm:"primary_key"` - EntryTime time.Time `json:"entry_time"` - ExitTime time.Time `json:"exit_time"` - StudentNumber uint `json:"student_number" gorm:"not null;foreignKey"` + ID uint `json:"id" gorm:"primary_key"` + EntryTime time.Time `json:"entry_time" gorm:"not null"` + ExitTime *time.Time `json:"exit_time"` + StudentNumber uint `json:"student_number" gorm:"not null;foreignKey"` } diff --git a/api/src/model/entry_test.go b/api/src/model/entry_test.go new file mode 100644 index 0000000..e24c534 --- /dev/null +++ b/api/src/model/entry_test.go @@ -0,0 +1,7 @@ +package model_test + +import "testing" + +func TestEntry_Nooop(t *testing.T) { + t.Log("Noop") +} diff --git a/api/src/model/user.go b/api/src/model/user.go index 9be0d15..38fa54a 100644 --- a/api/src/model/user.go +++ b/api/src/model/user.go @@ -1,8 +1,6 @@ package model -import ( - "time" -) +import "time" type User struct { StudentNumber uint `json:"student_number" gorm:"primary_key"` diff --git a/api/src/model/user_test.go b/api/src/model/user_test.go new file mode 100644 index 0000000..41c499f --- /dev/null +++ b/api/src/model/user_test.go @@ -0,0 +1,7 @@ +package model_test + +import "testing" + +func TestUser_Nooop(t *testing.T) { + t.Log("Noop") +} diff --git a/api/src/repository/entry_repository.go b/api/src/repository/entry_repository.go new file mode 100644 index 0000000..10803ae --- /dev/null +++ b/api/src/repository/entry_repository.go @@ -0,0 +1,43 @@ +package repository + +import ( + "api/model" + + "gorm.io/gorm" +) + +type IEntryRepository interface { + CreateEntry(entry *model.Entry) error + UpdateEntry(entry *model.Entry) error + GetStudentNumberWithNullExitTime(entry *model.Entry, studentNumber uint) error +} + +type EntryRepository struct { + db *gorm.DB +} + +func NewEntryRepository(db *gorm.DB) *EntryRepository { + return &EntryRepository{db} +} + +func (er *EntryRepository) CreateEntry(entry *model.Entry) error { + if err := er.db.Create(&entry).Error; err != nil { + return err + } + + return nil +} + +func (er *EntryRepository) UpdateEntry(entry *model.Entry) error { + if err := er.db.Model(&model.Entry{}).Where("id=?", entry.ID).Update("exit_time", entry.ExitTime).Error; err != nil { + return err + } + return nil +} + +func (er *EntryRepository) GetStudentNumberWithNullExitTime(entry *model.Entry, studentNumber uint) error { + if err := er.db.Where("student_number=? AND exit_time IS NULL", studentNumber).FirstOrInit(&entry).Error; err != nil { + return err + } + return nil +} diff --git a/api/src/repository/entry_repository_test.go b/api/src/repository/entry_repository_test.go new file mode 100644 index 0000000..446e935 --- /dev/null +++ b/api/src/repository/entry_repository_test.go @@ -0,0 +1,118 @@ +package repository_test + +import ( + "api/model" + "api/repository" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +func NewDBMock() (*gorm.DB, sqlmock.Sqlmock, error) { + db, mock, err := sqlmock.New() + if err != nil { + return nil, nil, err + } + + gormDB, err := gorm.Open(mysql.Dialector{Config: &mysql.Config{DriverName: "mysql", Conn: db, SkipInitializeWithVersion: true}}, &gorm.Config{}) + if err != nil { + return nil, nil, err + } + + return gormDB, mock, nil +} + +func TestEntryRepository_CreateEntry(t *testing.T) { + gormDB, mock, err := NewDBMock() + if err != nil { + t.Errorf(err.Error()) + } + + ter := repository.IEntryRepository(repository.NewEntryRepository(gormDB)) + + sampleTime := time.Unix(0, 0) + entry := model.Entry{ + EntryTime: sampleTime, + ExitTime: &sampleTime, + StudentNumber: uint(20122027), + } + + mock.ExpectBegin() + mock.ExpectExec("INSERT INTO `entries` \\(`entry_time`,`exit_time`,`student_number`\\) VALUES \\(\\?,\\?,\\?\\)"). + WithArgs(entry.EntryTime, entry.ExitTime, entry.StudentNumber). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectCommit() + + if err = ter.CreateEntry(&entry); err != nil { + t.Errorf(err.Error()) + } + + if err = mock.ExpectationsWereMet(); err != nil { + t.Errorf(err.Error()) + } +} + +func TestEntryRepository_UpdateEntry(t *testing.T) { + gormDB, mock, err := NewDBMock() + if err != nil { + t.Errorf(err.Error()) + } + + ter := repository.IEntryRepository(repository.NewEntryRepository(gormDB)) + + sampleTime := time.Unix(0, 0) + entry := model.Entry{ + ID: uint(1), + EntryTime: sampleTime, + ExitTime: &sampleTime, + StudentNumber: uint(20122027), + } + + mock.ExpectBegin() + mock.ExpectExec("UPDATE `entries` SET `exit_time`=\\? WHERE id=\\?"). + WithArgs(entry.EntryTime, entry.ID). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectCommit() + + if err = ter.UpdateEntry(&entry); err != nil { + t.Errorf(err.Error()) + } + + if err = mock.ExpectationsWereMet(); err != nil { + t.Errorf(err.Error()) + } +} + +func TestEntryRepository_GetStudentNumberWithNullExitTime(t *testing.T) { + gormDB, mock, err := NewDBMock() + if err != nil { + t.Error(err) + } + + ter := repository.IEntryRepository(repository.NewEntryRepository(gormDB)) + + sampleTime := time.Unix(0, 0) + entry := model.Entry{ + EntryTime: sampleTime, + ExitTime: &sampleTime, + StudentNumber: uint(20122027), + } + + rows := sqlmock.NewRows([]string{"id", "entry_time", "exit_time", "student_number"}). + AddRow(1, sampleTime, nil, entry.StudentNumber) + + mock.ExpectQuery("SELECT \\* FROM `entries` WHERE student_number=\\? AND exit_time IS NULL ORDER BY `entries`.`id` LIMIT 1"). + WithArgs(entry.StudentNumber). + WillReturnRows(rows) + + if err = ter.GetStudentNumberWithNullExitTime(&entry, entry.StudentNumber); err != nil { + t.Error(err) + } + + if err = mock.ExpectationsWereMet(); err != nil { + t.Error(err) + } +} diff --git a/api/src/repository/user_repository.go b/api/src/repository/user_repository.go new file mode 100644 index 0000000..56efc86 --- /dev/null +++ b/api/src/repository/user_repository.go @@ -0,0 +1,42 @@ +package repository + +import ( + "api/model" + + "gorm.io/gorm" +) + +type IUserRepository interface { + CreateUser(user *model.User) error + UpdateUser(user *model.User) error + GetUserByStudentNumber(user *model.User, studentNumber uint) error +} + +type UserRepository struct { + db *gorm.DB +} + +func NewUserRepository(db *gorm.DB) *UserRepository { + return &UserRepository{db} +} + +func (ur *UserRepository) CreateUser(user *model.User) error { + if err := ur.db.Create(user).Error; err != nil { + return err + } + return nil +} + +func (ur *UserRepository) UpdateUser(user *model.User) error { + if err := ur.db.Save(user).Error; err != nil { + return err + } + return nil +} + +func (ur *UserRepository) GetUserByStudentNumber(user *model.User, studentNumber uint) error { + if err := ur.db.Where("student_number=?", studentNumber).FirstOrInit(user).Error; err != nil { + return err + } + return nil +} diff --git a/api/src/repository/user_repository_test.go b/api/src/repository/user_repository_test.go new file mode 100644 index 0000000..4f1090d --- /dev/null +++ b/api/src/repository/user_repository_test.go @@ -0,0 +1,110 @@ +package repository_test + +import ( + "api/model" + "api/repository" + "database/sql/driver" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/stretchr/testify/assert" +) + +type AnyTime struct{} + +func (a AnyTime) Match(v driver.Value) bool { + _, ok := v.(time.Time) + return ok +} + +func TestUserRepository_CreateUser(t *testing.T) { + sampleStudentNumber := uint(20122027) + sampleName := "カイシ タロウ" + sampleTime := time.Unix(0, 0) + + gormDB, mock, err := NewDBMock() + if err != nil { + t.Errorf(err.Error()) + } + + tr := repository.IUserRepository(repository.NewUserRepository(gormDB)) + + sampleUser := model.User{ + StudentNumber: sampleStudentNumber, + Name: sampleName, + CreatedAt: sampleTime, + UpdatedAt: sampleTime, + } + + mock.ExpectBegin() + mock.ExpectExec("INSERT INTO `users` \\(`name`,`created_at`,`updated_at`,`student_number`\\) VALUES \\(\\?,\\?,\\?,\\?\\)"). + WithArgs(sampleName, sampleTime, sampleTime, sampleStudentNumber). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectCommit() + + if err = tr.CreateUser(&sampleUser); err != nil { + t.Errorf(err.Error()) + } + + if err = mock.ExpectationsWereMet(); err != nil { + t.Errorf(err.Error()) + } +} + +func TestUserRepository_UpdateUser(t *testing.T) { + gormDB, mock, err := NewDBMock() + if err != nil { + t.Errorf(err.Error()) + } + + tr := repository.IUserRepository(repository.NewUserRepository(gormDB)) + + sampleUser := model.User{ + StudentNumber: uint(20122027), + Name: "カイシ タロウ", + CreatedAt: time.Unix(0, 0), + UpdatedAt: time.Unix(0, 0), + } + + mock.ExpectBegin() + mock.ExpectExec("UPDATE `users` SET `name`=\\?,`created_at`=\\?,`updated_at`=\\? WHERE `student_number` = \\?"). + WithArgs(sampleUser.Name, sampleUser.CreatedAt, AnyTime{}, sampleUser.StudentNumber). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectCommit() + + if err = tr.UpdateUser(&sampleUser); err != nil { + t.Errorf(err.Error()) + } + + if err = mock.ExpectationsWereMet(); err != nil { + t.Errorf(err.Error()) + } +} + +func TestUserRepository_GetUserByStudentNumber(t *testing.T) { + gormDB, mock, err := NewDBMock() + if err != nil { + t.Errorf(err.Error()) + } + + tr := repository.IUserRepository(repository.NewUserRepository(gormDB)) + + sampleUser := &model.User{ + Name: "カイシ タロウ", + CreatedAt: time.Now().Round(time.Second), + UpdatedAt: time.Now().Round(time.Second), + StudentNumber: uint(20122027), + } + + rows := sqlmock.NewRows([]string{"name", "created_at", "updated_at", "student_number"}). + AddRow(sampleUser.Name, sampleUser.CreatedAt, sampleUser.UpdatedAt, sampleUser.StudentNumber) + mock.ExpectQuery("^SELECT \\* FROM `users` WHERE student_number=\\? ORDER BY `users`.`student_number` LIMIT 1$"). + WithArgs(sampleUser.StudentNumber).WillReturnRows(rows) + + responseUser := &model.User{} + err = tr.GetUserByStudentNumber(responseUser, sampleUser.StudentNumber) + + assert.NoError(t, err) + assert.Equal(t, sampleUser, responseUser) +} diff --git a/api/src/router/router.go b/api/src/router/router.go new file mode 100644 index 0000000..5dac12f --- /dev/null +++ b/api/src/router/router.go @@ -0,0 +1,13 @@ +package router + +import ( + "api/controller" + + "github.com/labstack/echo" +) + +func NewRouter(c controller.IUserAndEntryController) *echo.Echo { + e := echo.New() + e.POST("/", c.HandleUserAndEntry) + return e +} diff --git a/api/src/router/router_test.go b/api/src/router/router_test.go new file mode 100644 index 0000000..67245a6 --- /dev/null +++ b/api/src/router/router_test.go @@ -0,0 +1,30 @@ +package router_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/labstack/echo" + "github.com/stretchr/testify/assert" + + "api/router" +) + +type mockUserAndEntryController struct{} + +func (m *mockUserAndEntryController) HandleUserAndEntry(c echo.Context) error { + return c.String(http.StatusOK, "Hello, World!") +} + +func TestNewRouter(t *testing.T) { + ctrl := &mockUserAndEntryController{} + router := router.NewRouter(ctrl) + + req := httptest.NewRequest(http.MethodPost, "/", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "Hello, World!", rec.Body.String()) +} diff --git a/api/src/usecase/entry_usecase.go b/api/src/usecase/entry_usecase.go new file mode 100644 index 0000000..ddba212 --- /dev/null +++ b/api/src/usecase/entry_usecase.go @@ -0,0 +1,63 @@ +package usecase + +import ( + "api/model" + "api/repository" + "api/validator" + "time" +) + +type IEntryUsecase interface { + EntryOrExit(studentNumber uint, timestamp time.Time) (string, error) +} + +type EntryUsecase struct { + er repository.IEntryRepository + ev validator.IEntryValidator +} + +func NewEntryUsecase(er repository.IEntryRepository, ev validator.IEntryValidator) IEntryUsecase { + return &EntryUsecase{er: er, ev: ev} +} + +func (eu *EntryUsecase) EntryOrExit(studentNumber uint, timestamp time.Time) (string, error) { + if err := eu.ev.StudentNumberValidation(studentNumber); err != nil { + return "", err + } + + newEntry := model.Entry{} + if err := eu.er.GetStudentNumberWithNullExitTime(&newEntry, studentNumber); err != nil { + return "", err + } + + //entry + if newEntry.ID == 0 { + newEntry = model.Entry{ + EntryTime: timestamp, + ExitTime: nil, + StudentNumber: studentNumber, + } + + if err := eu.ev.EntryValidation(newEntry); err != nil { + return "", err + } + + if err := eu.er.CreateEntry(&newEntry); err != nil { + return "", err + } + return "entry success", nil + } + + //exit + newEntry.ExitTime = ×tamp + + if err := eu.ev.EntryValidation(newEntry); err != nil { + return "", err + } + + if err := eu.er.UpdateEntry(&newEntry); err != nil { + return "", err + } + + return "exit success", nil +} diff --git a/api/src/usecase/entry_usecase_test.go b/api/src/usecase/entry_usecase_test.go new file mode 100644 index 0000000..f349ab9 --- /dev/null +++ b/api/src/usecase/entry_usecase_test.go @@ -0,0 +1,93 @@ +package usecase_test + +import ( + "api/model" + "api/repository" + "api/usecase" + "api/validator" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/stretchr/testify/assert" + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +func NewDBMock() (*gorm.DB, sqlmock.Sqlmock, error) { + db, mock, err := sqlmock.New() + if err != nil { + return nil, nil, err + } + + gormDB, err := gorm.Open(mysql.Dialector{Config: &mysql.Config{DriverName: "mysql", Conn: db, SkipInitializeWithVersion: true}}, &gorm.Config{}) + if err != nil { + return nil, nil, err + } + + return gormDB, mock, nil +} + +func TestEntryUsecase_EntryOrExit(t *testing.T) { + gormDB, mock, err := NewDBMock() + if err != nil { + t.Errorf(err.Error()) + } + + eu := usecase.IEntryUsecase( + usecase.NewEntryUsecase( + repository.IEntryRepository(repository.NewEntryRepository(gormDB)), + validator.IEntryValidator(validator.NewEntryValidator()), + ), + ) + + sampleStudentNumber := uint(20122027) + sampleTimestamp := time.Now().Add(-time.Second) + TimeValidationMin := int64(1704034800) + + t.Run("Entry", func(t *testing.T) { + //GetStudentNumberWithNullExitTime + initEntry := model.Entry{} + rows := sqlmock.NewRows([]string{"id", "entry_time", "exit_time", "student_number"}). + AddRow(initEntry.ID, initEntry.EntryTime, initEntry.ExitTime, initEntry.StudentNumber) + mock.ExpectQuery("SELECT \\* FROM `entries` WHERE student_number=\\? AND exit_time IS NULL ORDER BY `entries`.`id` LIMIT 1"). + WithArgs(sampleStudentNumber). + WillReturnRows(rows) + + //CreateEntry + mock.ExpectBegin() + mock.ExpectExec("INSERT INTO `entries` \\(`entry_time`,`exit_time`,`student_number`\\) VALUES \\(\\?,\\?,\\?\\)"). + WithArgs(sampleTimestamp, nil, sampleStudentNumber). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectCommit() + + result, err := eu.EntryOrExit(sampleStudentNumber, sampleTimestamp) + assert.NoError(t, err) + assert.Equal(t, "entry success", result) + }) + + t.Run("Exit", func(t *testing.T) { + storedEntry := model.Entry{ + ID: 1, + EntryTime: time.Unix(TimeValidationMin, 0), + ExitTime: nil, + StudentNumber: sampleStudentNumber, + } + + rows := sqlmock.NewRows([]string{"id", "entry_time", "exit_time", "student_number"}). + AddRow(storedEntry.ID, storedEntry.EntryTime, storedEntry.ExitTime, storedEntry.StudentNumber) + mock.ExpectQuery("SELECT \\* FROM `entries` WHERE student_number=\\? AND exit_time IS NULL ORDER BY `entries`.`id` LIMIT 1"). + WithArgs(sampleStudentNumber). + WillReturnRows(rows) + + mock.ExpectBegin() + mock.ExpectExec("UPDATE `entries` SET `exit_time`=\\? WHERE id=\\?"). + WithArgs(sampleTimestamp, storedEntry.ID). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectCommit() + + result, err := eu.EntryOrExit(sampleStudentNumber, sampleTimestamp) + assert.NoError(t, err) + assert.Equal(t, "exit success", result) + }) +} diff --git a/api/src/usecase/user_usecase.go b/api/src/usecase/user_usecase.go new file mode 100644 index 0000000..645467d --- /dev/null +++ b/api/src/usecase/user_usecase.go @@ -0,0 +1,51 @@ +package usecase + +import ( + "api/model" + "api/repository" + "api/validator" +) + +type IUserUsecase interface { + CreateOrUpdateUser(user model.User) (string, error) +} + +type UserUsecase struct { + ur repository.IUserRepository + uv validator.IUserValidator +} + +func NewUserUsecase(ur repository.IUserRepository, uv validator.IUserValidator) IUserUsecase { + return &UserUsecase{ur: ur, uv: uv} +} + +func (uu *UserUsecase) CreateOrUpdateUser(user model.User) (string, error) { + //Validate user + if eer := uu.uv.UserValidation(user); eer != nil { + return "", eer + } + + DBUser := model.User{} + + if err := uu.ur.GetUserByStudentNumber(&DBUser, user.StudentNumber); err != nil { + return "", err + } + + //Create user + if DBUser.StudentNumber == 0 { + if err := uu.ur.CreateUser(&user); err != nil { + return "", err + } + return "User created", nil + } + + //Update user + if DBUser.Name != user.Name { + if err := uu.ur.UpdateUser(&user); err != nil { + return "", err + } + return "User updated", nil + } + + return "User already exists", nil +} diff --git a/api/src/usecase/user_usecase_test.go b/api/src/usecase/user_usecase_test.go new file mode 100644 index 0000000..a25b2ef --- /dev/null +++ b/api/src/usecase/user_usecase_test.go @@ -0,0 +1,112 @@ +package usecase_test + +import ( + "api/model" + "api/repository" + "api/usecase" + "api/validator" + "database/sql/driver" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/stretchr/testify/assert" +) + +type AnyTime struct{} + +func (a AnyTime) Match(v driver.Value) bool { + _, ok := v.(time.Time) + return ok +} + +func TestUserUsecase_CreateOrUpdateUser(t *testing.T) { + gormDB, mock, err := NewDBMock() + if err != nil { + t.Errorf(err.Error()) + } + + uu := usecase.IUserUsecase( + usecase.NewUserUsecase( + repository.IUserRepository(repository.NewUserRepository(gormDB)), + validator.IUserValidator(validator.NewUserValidator()), + ), + ) + + t.Run("CreateUser", func(t *testing.T) { + user := model.User{ + StudentNumber: 20122027, + Name: "カイシ タロウ", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + //GetUserByStudentNumber + storedUser := model.User{} + rows := sqlmock.NewRows([]string{"name", "created_at", "updated_at", "student_number"}). + AddRow(storedUser.Name, storedUser.CreatedAt, storedUser.UpdatedAt, storedUser.StudentNumber) + mock.ExpectQuery("^SELECT \\* FROM `users` WHERE student_number=\\? ORDER BY `users`.`student_number` LIMIT 1$"). + WithArgs(user.StudentNumber).WillReturnRows(rows) + + //CreateUser + mock.ExpectBegin() + mock.ExpectExec("INSERT INTO `users` \\(`name`,`created_at`,`updated_at`,`student_number`\\) VALUES \\(\\?,\\?,\\?,\\?\\)"). + WithArgs(user.Name, user.CreatedAt, user.UpdatedAt, user.StudentNumber). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectCommit() + + result, err := uu.CreateOrUpdateUser(user) + assert.NoError(t, err) + assert.Equal(t, "User created", result) + }) + + t.Run("UpdateUser", func(t *testing.T) { + user := model.User{ + StudentNumber: 20122027, + Name: "カイシ タロウ", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + //GetUserByStudentNumber + storedUser := model.User{ + StudentNumber: 20122027, + Name: "ヨネヤマ タロウ", + CreatedAt: time.Now().Add(-time.Second), + UpdatedAt: time.Now().Add(-time.Second), + } + rows := sqlmock.NewRows([]string{"name", "created_at", "updated_at", "student_number"}). + AddRow(storedUser.Name, storedUser.CreatedAt, storedUser.UpdatedAt, storedUser.StudentNumber) + mock.ExpectQuery("^SELECT \\* FROM `users` WHERE student_number=\\? ORDER BY `users`.`student_number` LIMIT 1$"). + WithArgs(user.StudentNumber).WillReturnRows(rows) + + mock.ExpectBegin() + mock.ExpectExec("UPDATE `users` SET `name`=\\?,`created_at`=\\?,`updated_at`=\\? WHERE `student_number` = \\?"). + WithArgs(user.Name, user.CreatedAt, AnyTime{}, user.StudentNumber). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectCommit() + + result, err := uu.CreateOrUpdateUser(user) + assert.NoError(t, err) + assert.Equal(t, "User updated", result) + }) + + t.Run("AlreadyExistsUser", func(t *testing.T) { + user := model.User{ + StudentNumber: 20122027, + Name: "カイシ タロウ", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + //GetUserByStudentNumber + rows := sqlmock.NewRows([]string{"name", "created_at", "updated_at", "student_number"}). + AddRow(user.Name, user.CreatedAt, user.UpdatedAt, user.StudentNumber) + mock.ExpectQuery("^SELECT \\* FROM `users` WHERE student_number=\\? ORDER BY `users`.`student_number` LIMIT 1$"). + WithArgs(user.StudentNumber).WillReturnRows(rows) + + result, err := uu.CreateOrUpdateUser(user) + assert.NoError(t, err) + assert.Equal(t, "User already exists", result) + }) +} diff --git a/api/src/validator/entry_validator.go b/api/src/validator/entry_validator.go new file mode 100644 index 0000000..16b37df --- /dev/null +++ b/api/src/validator/entry_validator.go @@ -0,0 +1,112 @@ +package validator + +import ( + "api/model" + "os" + "strconv" + "time" + + validation "github.com/go-ozzo/ozzo-validation/v4" +) + +type IEntryValidator interface { + StudentNumberValidation(studentNumber uint) error + EntryValidation(entry model.Entry) error +} + +type EntryValidator struct{} + +func NewEntryValidator() IEntryValidator { + return &EntryValidator{} +} + +func (ev *EntryValidator) StudentNumberValidation(studentNumber uint) error { + studentNumberMin, err := strconv.ParseUint(os.Getenv("STUDENT_NUMBER_MIN"), 10, 64) + if err != nil { + return err + } + + studentNumberMax, err := strconv.ParseUint(os.Getenv("STUDENT_NUMBER_MAX"), 10, 64) + if err != nil { + return err + } + + return validation.Validate(studentNumber, + validation.Required.Error("student number is required"), + validation.Min(studentNumberMin).Error("student number must be greater than 10000000"), + validation.Max(studentNumberMax).Error("student number must be less than 39999999"), + ) +} + +type TimeAfter struct { + required time.Time +} + +func (t TimeAfter) Validate(value interface{}) error { + if timeValue, ok := value.(*time.Time); ok { + if timeValue.Before(t.required) { + return validation.NewError("validation_min", "must be after "+t.required.String()) + } + } + return nil +} + +type TimeBefore struct { + required time.Time +} + +func (t TimeBefore) Validate(value interface{}) error { + if timeValue, ok := value.(*time.Time); ok { + if timeValue.After(t.required) { + return validation.NewError("validation_max", "must be before "+t.required.String()) + } + } + return nil +} + +func (ev *EntryValidator) EntryValidation(entry model.Entry) error { + studentNumberMin, err := strconv.ParseUint(os.Getenv("STUDENT_NUMBER_MIN"), 10, 64) + if err != nil { + return err + } + + studentNumberMax, err := strconv.ParseUint(os.Getenv("STUDENT_NUMBER_MAX"), 10, 64) + if err != nil { + return err + } + + TimeValidationMin, err := strconv.ParseInt(os.Getenv("TIME_VALIDATION_MIN"), 10, 64) + if err != nil { + return err + } + + err = validation.ValidateStruct(&entry, + validation.Field( + &entry.EntryTime, + validation.Required.Error("entry time is required"), + validation.Min(time.Unix(TimeValidationMin, 0)).Error("must be after "+time.Unix(TimeValidationMin, 0).String()), + validation.Max(time.Now()).Error("must be before "+time.Now().Round(time.Second).String()), + ), + validation.Field( + &entry.StudentNumber, + validation.Required.Error("student number is required"), + validation.Min(studentNumberMin).Error("student number must be greater than 10000000"), + validation.Max(studentNumberMax).Error("student number must be less than 39999999"), + ), + ) + if err != nil { + return err + } + + if entry.ExitTime != nil { + err = validation.ValidateStruct(&entry, + validation.Field( + &entry.ExitTime, + TimeAfter{required: entry.EntryTime}, + TimeBefore{required: time.Now().Round(time.Second)}, + ), + ) + } + + return err +} diff --git a/api/src/validator/entry_validator_test.go b/api/src/validator/entry_validator_test.go new file mode 100644 index 0000000..a79a219 --- /dev/null +++ b/api/src/validator/entry_validator_test.go @@ -0,0 +1,288 @@ +package validator_test + +import ( + "api/model" + "api/validator" + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestEntryValidator_StudentNumberValidation(t *testing.T) { + var err error + StudentNumberMin := 10000000 + StudentNumberMax := 39999999 + sampleStudentNumber := uint(20122027) + + t.Setenv("STUDENT_NUMBER_MIN", strconv.Itoa(StudentNumberMin)) + t.Setenv("STUDENT_NUMBER_MAX", strconv.Itoa(StudentNumberMax)) + + ev := validator.IEntryValidator(validator.NewEntryValidator()) + + //Test case 1 正しいケース + t.Run("valid", func(t *testing.T) { + err = ev.StudentNumberValidation(sampleStudentNumber) + assert.NoError(t, err) + }) + + //Test case 2 STUDENT_NUMBER_MINが"無効 + t.Run("invalid_STUDENT_NUMBER_MIN", func(t *testing.T) { + t.Setenv("STUDENT_NUMBER_MIN", "") + err = ev.StudentNumberValidation(sampleStudentNumber) + assert.Equal(t, "strconv.ParseUint: parsing \"\": invalid syntax", err.Error()) + }) + + //Test case 3 STUDENT_NUMBER_MAXが無効 + t.Run("invalid_STUDENT_NUMBER_MAX", func(t *testing.T) { + t.Setenv("STUDENT_NUMBER_MAX", "") + err = ev.StudentNumberValidation(sampleStudentNumber) + assert.Equal(t, "strconv.ParseUint: parsing \"\": invalid syntax", err.Error()) + }) + + //Test case 4 StudentNumberが欠損している + t.Run("not_required", func(t *testing.T) { + err = ev.StudentNumberValidation(0) + exceptedErrorMessages := "student number is required" + assert.Equal(t, exceptedErrorMessages, err.Error()) + }) + + //Test case 5 StudentNumberが最小値よりも小さい + t.Run("below_minimum", func(t *testing.T) { + err = ev.StudentNumberValidation(uint(StudentNumberMin - 1)) + exceptedErrorMessages := "student number must be greater than 10000000" + assert.Equal(t, exceptedErrorMessages, err.Error()) + }) + + //Test case 6 StudentNumberが最大値よりも大きい + t.Run("above_maximum", func(t *testing.T) { + err = ev.StudentNumberValidation(uint(StudentNumberMax + 1)) + exceptedErrorMessages := "student number must be less than 39999999" + assert.Equal(t, exceptedErrorMessages, err.Error()) + }) + + //Test case 7 StudentNumberが最小値 + t.Run("minimum", func(t *testing.T) { + err = ev.StudentNumberValidation(uint(StudentNumberMin)) + assert.NoError(t, err) + }) + + //Test case 8 最大値のStudentNumber + t.Run("maximum", func(t *testing.T) { + err = ev.StudentNumberValidation(uint(StudentNumberMax)) + assert.NoError(t, err) + }) +} + +func TestEntryValidator_EntryValidation(t *testing.T) { + var err error + StudentNumberMin := 10000000 + StudentNumberMax := 39999999 + TimeValidationMin := int64(1704034800) + sampleStudentNumber := uint(20122027) + + t.Setenv("STUDENT_NUMBER_MIN", strconv.Itoa(StudentNumberMin)) + t.Setenv("STUDENT_NUMBER_MAX", strconv.Itoa(StudentNumberMax)) + t.Setenv("TIME_VALIDATION_MIN", strconv.FormatInt(TimeValidationMin, 10)) + + ev := validator.IEntryValidator(validator.NewEntryValidator()) + + validEntry := model.Entry{ + EntryTime: time.Now(), + ExitTime: nil, + StudentNumber: sampleStudentNumber, + } + + //Test case 1 正しいケース + t.Run("valid", func(t *testing.T) { + err = ev.EntryValidation(validEntry) + assert.NoError(t, err) + }) + + //Test case 2 STUDENT_NUMBER_MINが無効 + t.Run("invalid_STUDENT_NUMBER_MIN", func(t *testing.T) { + t.Setenv("STUDENT_NUMBER_MIN", "") + err = ev.EntryValidation(validEntry) + assert.Equal(t, "strconv.ParseUint: parsing \"\": invalid syntax", err.Error()) + }) + + //Test case 3 STUDENT_NUMBER_MAXが無効 + t.Run("invalid_STUDENT_NUMBER_MAX", func(t *testing.T) { + t.Setenv("STUDENT_NUMBER_MAX", "") + err = ev.EntryValidation(validEntry) + assert.Equal(t, "strconv.ParseUint: parsing \"\": invalid syntax", err.Error()) + }) + + //Test case 4 TIME_VALIDATION_MINが無効 + t.Run("invalid_TIME_VALIDATION_MIN", func(t *testing.T) { + t.Setenv("TIME_VALIDATION_MIN", "") + err = ev.EntryValidation(validEntry) + assert.Equal(t, "strconv.ParseInt: parsing \"\": invalid syntax", err.Error()) + }) + + //Test case 5 EntryTimeが欠損している + t.Run("not_required_EntryTime", func(t *testing.T) { + entry := model.Entry{ + ExitTime: nil, + StudentNumber: sampleStudentNumber, + } + err = ev.EntryValidation(entry) + exceptedErrorMessages := "entry_time: entry time is required." + assert.Equal(t, exceptedErrorMessages, err.Error()) + }) + + //Test case 6 EntryTimeが最小値よりも小さい + t.Run("below_minimum_EntryTime", func(t *testing.T) { + entry := model.Entry{ + EntryTime: time.Unix(TimeValidationMin-1, 0), + ExitTime: nil, + StudentNumber: sampleStudentNumber, + } + err = ev.EntryValidation(entry) + exceptedErrorMessages := "entry_time: must be after " + time.Unix(TimeValidationMin, 0).String() + "." + assert.Equal(t, exceptedErrorMessages, err.Error()) + }) + + //Test case 7 EntryTimeが最大値よりも大きい + t.Run("above_maximum_EntryTime", func(t *testing.T) { + entry := model.Entry{ + EntryTime: time.Now().Add(time.Second), + ExitTime: nil, + StudentNumber: sampleStudentNumber, + } + err = ev.EntryValidation(entry) + exceptedErrorMessages := "entry_time: must be before " + time.Now().Round(time.Second).String() + "." + assert.Equal(t, exceptedErrorMessages, err.Error()) + }) + + //Test case 8 EntryTimeが最小値 + t.Run("minimum_EntryTime", func(t *testing.T) { + entry := model.Entry{ + EntryTime: time.Unix(TimeValidationMin, 0), + ExitTime: nil, + StudentNumber: sampleStudentNumber, + } + err = ev.EntryValidation(entry) + assert.NoError(t, err) + }) + + //Test case 9 EntryTimeが最大値 + t.Run("maximum_EntryTime", func(t *testing.T) { + entry := model.Entry{ + EntryTime: time.Now(), + ExitTime: nil, + StudentNumber: sampleStudentNumber, + } + err = ev.EntryValidation(entry) + assert.NoError(t, err) + }) + + //Test case 10 StudentNumberが欠損している + t.Run("not_required_StudentNumber", func(t *testing.T) { + entry := model.Entry{ + EntryTime: time.Now(), + ExitTime: nil, + } + err = ev.EntryValidation(entry) + exceptedErrorMessages := "student_number: student number is required." + assert.Equal(t, exceptedErrorMessages, err.Error()) + }) + + //Test case 11 StudentNumberが最小値よりも小さい + t.Run("below_minimum_StudentNumber", func(t *testing.T) { + entry := model.Entry{ + EntryTime: time.Now(), + ExitTime: nil, + StudentNumber: 9999999, + } + err = ev.EntryValidation(entry) + exceptedErrorMessages := "student_number: student number must be greater than 10000000." + assert.Equal(t, exceptedErrorMessages, err.Error()) + }) + + //Test case 12 StudentNumberが最大値よりも大きい + t.Run("above_maximum_StudentNumber", func(t *testing.T) { + entry := model.Entry{ + EntryTime: time.Now(), + ExitTime: nil, + StudentNumber: 40000000, + } + err = ev.EntryValidation(entry) + exceptedErrorMessages := "student_number: student number must be less than 39999999." + assert.Equal(t, exceptedErrorMessages, err.Error()) + }) + + //Test case 13 StudentNumberが最小値 + t.Run("minimum_StudentNumber", func(t *testing.T) { + entry := model.Entry{ + EntryTime: time.Now(), + ExitTime: nil, + StudentNumber: 10000000, + } + err = ev.EntryValidation(entry) + assert.NoError(t, err) + }) + + //Test case 14 StudentNumberが最大値 + t.Run("maximum_StudentNumber", func(t *testing.T) { + entry := model.Entry{ + EntryTime: time.Now(), + ExitTime: nil, + StudentNumber: 39999999, + } + err = ev.EntryValidation(entry) + assert.NoError(t, err) + }) + + //Test case 15 ExitTimeが最小値よりも小さい + t.Run("below_minimum_ExitTime", func(t *testing.T) { + exitTime := time.Now().Add(-time.Second) + entry := model.Entry{ + EntryTime: time.Now(), + ExitTime: &exitTime, + StudentNumber: sampleStudentNumber, + } + err = ev.EntryValidation(entry) + exceptedErrorMessages := "exit_time: must be after " + entry.EntryTime.String() + "." + assert.Equal(t, exceptedErrorMessages, err.Error()) + }) + + //Test case 16 ExitTimeが最大値よりも大きい + t.Run("above_maximum_ExitTime", func(t *testing.T) { + exitTime := time.Now().Add(time.Second) + entry := model.Entry{ + EntryTime: time.Now(), + ExitTime: &exitTime, + StudentNumber: sampleStudentNumber, + } + err = ev.EntryValidation(entry) + exceptedErrorMessages := "exit_time: must be before " + time.Now().Round(time.Second).String() + "." + assert.Equal(t, exceptedErrorMessages, err.Error()) + }) + + //Test case 17 ExitTimeが最小値 + t.Run("minimum_ExitTime", func(t *testing.T) { + exitTime := time.Unix(TimeValidationMin, 0) + entry := model.Entry{ + EntryTime: exitTime, + ExitTime: &exitTime, + StudentNumber: sampleStudentNumber, + } + err = ev.EntryValidation(entry) + assert.NoError(t, err) + }) + + //Test case 18 ExitTimeが最大値 + t.Run("maximum_ExitTime", func(t *testing.T) { + exitTime := time.Now().Round(time.Second) + entry := model.Entry{ + EntryTime: time.Unix(TimeValidationMin, 0), + ExitTime: &exitTime, + StudentNumber: sampleStudentNumber, + } + err = ev.EntryValidation(entry) + assert.NoError(t, err) + }) + +} diff --git a/api/src/validator/user_validator.go b/api/src/validator/user_validator.go new file mode 100644 index 0000000..d8a9bb2 --- /dev/null +++ b/api/src/validator/user_validator.go @@ -0,0 +1,84 @@ +package validator + +import ( + "api/model" + "os" + "strconv" + "time" + + validation "github.com/go-ozzo/ozzo-validation/v4" +) + +type IUserValidator interface { + UserValidation(user model.User) error +} + +type UserValidator struct{} + +func NewUserValidator() IUserValidator { + return &UserValidator{} +} + +func (uv *UserValidator) UserValidation(user model.User) error { + studentNumberMin, err := strconv.ParseUint(os.Getenv("STUDENT_NUMBER_MIN"), 10, 64) + if err != nil { + return err + } + + studentNumberMax, err := strconv.ParseUint(os.Getenv("STUDENT_NUMBER_MAX"), 10, 64) + if err != nil { + return err + } + + nameMinLength, err := strconv.Atoi(os.Getenv("NAME_MIN_LENGTH")) + if err != nil { + return err + } + + nameMaxLength, err := strconv.Atoi(os.Getenv("NAME_MAX_LENGTH")) + if err != nil { + return err + } + + TimeValidationMin, err := strconv.ParseInt(os.Getenv("TIME_VALIDATION_MIN"), 10, 64) + if err != nil { + return err + } + + err = validation.Validate( + &user.StudentNumber, + validation.Required.Error("student number is required"), + validation.Min(studentNumberMin).Error("student number must be greater than 10000000"), + validation.Max(studentNumberMax).Error("student number must be less than 39999999"), + ) + if err != nil { + return err + } + + err = validation.Validate( + &user.Name, + validation.Required.Error("name is required"), + validation.RuneLength(nameMinLength, nameMaxLength).Error("name must be between 2 and 32 characters"), + ) + if err != nil { + return err + } + + err = validation.Validate( + &user.CreatedAt, + validation.Required.Error("created at is required"), + validation.Min(time.Unix(TimeValidationMin, 0)).Error("created at must be after "+time.Unix(TimeValidationMin, 0).String()), + validation.Max(time.Now()).Error("created at must be before "+time.Now().Round(time.Second).String()), + ) + if err != nil { + return err + } + + err = validation.Validate( + &user.UpdatedAt, + validation.Required.Error("updated at is required"), + validation.Min(user.CreatedAt).Error("updated at must be after "+time.Unix(TimeValidationMin, 0).String()), + validation.Max(time.Now()).Error("updated at must be before "+time.Now().Round(time.Second).String()), + ) + return err +} diff --git a/api/src/validator/user_validator_test.go b/api/src/validator/user_validator_test.go new file mode 100644 index 0000000..d06286c --- /dev/null +++ b/api/src/validator/user_validator_test.go @@ -0,0 +1,321 @@ +package validator_test + +import ( + "api/model" + "api/validator" + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestUserValidator_UserValidation(t *testing.T) { + + var err error + TimeValidationMin := int64(1704034800) + + t.Setenv("STUDENT_NUMBER_MIN", "10000000") + t.Setenv("STUDENT_NUMBER_MAX", "39999999") + t.Setenv("NAME_MIN_LENGTH", "2") + t.Setenv("NAME_MAX_LENGTH", "32") + t.Setenv("TIME_VALIDATION_MIN", strconv.FormatInt(TimeValidationMin, 10)) + + validUser := model.User{ + StudentNumber: uint(20122027), + Name: "カイシ タロウ", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + uv := validator.IUserValidator(validator.NewUserValidator()) + + //Test case 1 正しいケース + t.Run("valid", func(t *testing.T) { + err = uv.UserValidation(validUser) + assert.NoError(t, err) + }) + + //Test case 2 STUDENT_NUMBER_MINの値が無効 + t.Run("invalid_STUDENT_NUMBER_MIN", func(t *testing.T) { + t.Setenv("STUDENT_NUMBER_MIN", "") + err = uv.UserValidation(validUser) + assert.Equal(t, "strconv.ParseUint: parsing \"\": invalid syntax", err.Error()) + }) + + //Test case 3 STUDENT_NUMBER_MAXの値が無効 + t.Run("invalid_STUDENT_NUMBER_MAX", func(t *testing.T) { + t.Setenv("STUDENT_NUMBER_MAX", "") + err = uv.UserValidation(validUser) + assert.Equal(t, "strconv.ParseUint: parsing \"\": invalid syntax", err.Error()) + }) + + //Test case 4 NAME_MIN_LENGTHの値が無効 + t.Run("invalid_NAME_MIN_LENGTH", func(t *testing.T) { + t.Setenv("NAME_MIN_LENGTH", "") + err = uv.UserValidation(validUser) + assert.Equal(t, "strconv.Atoi: parsing \"\": invalid syntax", err.Error()) + }) + + //Test case 5 NAME_MAX_LENGTHの値が無効 + t.Run("invalid_NAME_MAX_LENGTH", func(t *testing.T) { + t.Setenv("NAME_MAX_LENGTH", "") + err = uv.UserValidation(validUser) + assert.Equal(t, "strconv.Atoi: parsing \"\": invalid syntax", err.Error()) + }) + + //Test case 6 TIME_VALIDATION_MINの値が無効 + t.Run("invalid_TIME_VALIDATION_MIN", func(t *testing.T) { + t.Setenv("TIME_VALIDATION_MIN", "") + err = uv.UserValidation(validUser) + assert.Equal(t, "strconv.ParseInt: parsing \"\": invalid syntax", err.Error()) + }) + + //Test case 7 StudentNumberが欠損している + t.Run("not_required_StudentNumber", func(t *testing.T) { + user := model.User{ + Name: validUser.Name, + CreatedAt: validUser.CreatedAt, + UpdatedAt: validUser.UpdatedAt, + } + err = uv.UserValidation(user) + exceptedErrorMessages := "student number is required" + assert.Equal(t, exceptedErrorMessages, err.Error()) + }) + + //Test case 8 StudentNumberが最小値よりも小さい + t.Run("below_minimum_StudentNumber", func(t *testing.T) { + user := model.User{ + StudentNumber: 9999999, + Name: validUser.Name, + CreatedAt: validUser.CreatedAt, + UpdatedAt: validUser.UpdatedAt, + } + err = uv.UserValidation(user) + exceptedErrorMessages := "student number must be greater than 10000000" + assert.Equal(t, exceptedErrorMessages, err.Error()) + }) + + //Test case 9 StudentNumberが最大値よりも大きい + t.Run("above_maximum_StudentNumber", func(t *testing.T) { + user := model.User{ + StudentNumber: 40000000, + Name: validUser.Name, + CreatedAt: validUser.CreatedAt, + UpdatedAt: validUser.UpdatedAt, + } + err = uv.UserValidation(user) + exceptedErrorMessages := "student number must be less than 39999999" + assert.Equal(t, exceptedErrorMessages, err.Error()) + }) + + // Test case 10 StudentNumberが最小値 + t.Run("minimum_StudentNumber", func(t *testing.T) { + user := model.User{ + StudentNumber: 10000000, + Name: validUser.Name, + CreatedAt: validUser.CreatedAt, + UpdatedAt: validUser.UpdatedAt, + } + err = uv.UserValidation(user) + assert.NoError(t, err) + }) + + // Test case 11 StudentNumberが最大値 + t.Run("maximum_StudentNumber", func(t *testing.T) { + user := model.User{ + StudentNumber: 39999999, + Name: validUser.Name, + CreatedAt: validUser.CreatedAt, + UpdatedAt: validUser.UpdatedAt, + } + err = uv.UserValidation(user) + assert.NoError(t, err) + }) + + //Test case 12 Nameが欠損している + t.Run("not_required_Name", func(t *testing.T) { + user := model.User{ + StudentNumber: validUser.StudentNumber, + CreatedAt: validUser.CreatedAt, + UpdatedAt: validUser.UpdatedAt, + } + err = uv.UserValidation(user) + exceptedErrorMessages := "name is required" + assert.Equal(t, exceptedErrorMessages, err.Error()) + }) + + //Test case 13 Nameが最小値よりも小さい + t.Run("below_minimum_Name", func(t *testing.T) { + user := model.User{ + StudentNumber: validUser.StudentNumber, + Name: "カ", + CreatedAt: validUser.CreatedAt, + UpdatedAt: validUser.UpdatedAt, + } + err = uv.UserValidation(user) + exceptedErrorMessages := "name must be between 2 and 32 characters" + assert.Equal(t, exceptedErrorMessages, err.Error()) + }) + + //Test case 14 Nameが最大値よりも大きい + t.Run("above_maximum_Name", func(t *testing.T) { + user := model.User{ + StudentNumber: validUser.StudentNumber, + Name: "あああああああああああああああああああああああああああああああああ", //33 characters + CreatedAt: validUser.CreatedAt, + UpdatedAt: validUser.UpdatedAt, + } + err = uv.UserValidation(user) + exceptedErrorMessages := "name must be between 2 and 32 characters" + assert.Equal(t, exceptedErrorMessages, err.Error()) + }) + + //Test case 15 Nameが最小値 + t.Run("minimum_Name", func(t *testing.T) { + user := model.User{ + StudentNumber: validUser.StudentNumber, + Name: "カイ", + CreatedAt: validUser.CreatedAt, + UpdatedAt: validUser.UpdatedAt, + } + err = uv.UserValidation(user) + assert.NoError(t, err) + }) + + //Test case 16 Nameが最大値 + t.Run("maximum_Name", func(t *testing.T) { + user := model.User{ + StudentNumber: validUser.StudentNumber, + Name: "ああああああああああああああああああああああああああああああああ", //31 characters + CreatedAt: validUser.CreatedAt, + UpdatedAt: validUser.UpdatedAt, + } + err = uv.UserValidation(user) + assert.NoError(t, err) + }) + + //Test case 17 CreatedAtが欠損している + t.Run("not_required_CreatedAt", func(t *testing.T) { + user := model.User{ + StudentNumber: validUser.StudentNumber, + Name: validUser.Name, + UpdatedAt: validUser.UpdatedAt, + } + err = uv.UserValidation(user) + exceptedErrorMessages := "created at is required" + assert.Equal(t, exceptedErrorMessages, err.Error()) + }) + + //Test case 18 CreatedAtが最小値よりも小さい + t.Run("below_minimum_CreatedAt", func(t *testing.T) { + user := model.User{ + StudentNumber: validUser.StudentNumber, + Name: validUser.Name, + CreatedAt: time.Unix(TimeValidationMin-1, 0), + UpdatedAt: validUser.UpdatedAt, + } + err = uv.UserValidation(user) + exceptedErrorMessages := "created at must be after " + time.Unix(TimeValidationMin, 0).String() + assert.Equal(t, exceptedErrorMessages, err.Error()) + }) + + //Test case 19 CreatedAtが最大値よりも大きい + t.Run("above_maximum_CreatedAt", func(t *testing.T) { + user := model.User{ + StudentNumber: validUser.StudentNumber, + Name: validUser.Name, + CreatedAt: time.Now().Add(time.Second), + UpdatedAt: time.Now().Add(time.Second), + } + err = uv.UserValidation(user) + exceptedErrorMessages := "created at must be before " + time.Now().Round(time.Second).String() + assert.Equal(t, exceptedErrorMessages, err.Error()) + }) + + //Test case 20 CreatedAtが最小値 + t.Run("minimum_CreatedAt", func(t *testing.T) { + user := model.User{ + StudentNumber: validUser.StudentNumber, + Name: validUser.Name, + CreatedAt: time.Unix(TimeValidationMin, 0), + UpdatedAt: validUser.UpdatedAt, + } + err = uv.UserValidation(user) + assert.NoError(t, err) + }) + + //Test case 21 CreatedAtが最大値 + t.Run("maximum_CreatedAt", func(t *testing.T) { + user := model.User{ + StudentNumber: validUser.StudentNumber, + Name: validUser.Name, + CreatedAt: time.Now().Add(-time.Second), + UpdatedAt: validUser.UpdatedAt, + } + err = uv.UserValidation(user) + assert.NoError(t, err) + }) + + //Test case 22 UpdatedAtが欠損している + t.Run("not_required_UpdatedAt", func(t *testing.T) { + user := model.User{ + StudentNumber: validUser.StudentNumber, + Name: validUser.Name, + CreatedAt: validUser.CreatedAt, + } + err = uv.UserValidation(user) + exceptedErrorMessages := "updated at is required" + assert.Equal(t, exceptedErrorMessages, err.Error()) + }) + + //Test case 23 UpdatedAtが最小値よりも小さい + t.Run("below_minimum_UpdatedAt", func(t *testing.T) { + user := model.User{ + StudentNumber: validUser.StudentNumber, + Name: validUser.Name, + CreatedAt: validUser.CreatedAt, + UpdatedAt: time.Unix(TimeValidationMin-1, 0), + } + err = uv.UserValidation(user) + exceptedErrorMessages := "updated at must be after " + time.Unix(TimeValidationMin, 0).String() + assert.Equal(t, exceptedErrorMessages, err.Error()) + }) + + //Test case 24 UpdatedAtが最大値よりも大きい + t.Run("above_maximum_UpdatedAt", func(t *testing.T) { + user := model.User{ + StudentNumber: validUser.StudentNumber, + Name: validUser.Name, + CreatedAt: validUser.CreatedAt, + UpdatedAt: time.Now().Add(time.Second), + } + err = uv.UserValidation(user) + exceptedErrorMessages := "updated at must be before " + time.Now().Round(time.Second).String() + assert.Equal(t, exceptedErrorMessages, err.Error()) + }) + + //Test case 25 UpdatedAtが最小値 + t.Run("minimum_UpdatedAt", func(t *testing.T) { + user := model.User{ + StudentNumber: validUser.StudentNumber, + Name: validUser.Name, + CreatedAt: time.Unix(TimeValidationMin, 0), + UpdatedAt: time.Unix(TimeValidationMin, 0), + } + err = uv.UserValidation(user) + assert.NoError(t, err) + }) + + //Test case 26 UpdatedAtが最大値 + t.Run("maximum_UpdatedAt", func(t *testing.T) { + user := model.User{ + StudentNumber: validUser.StudentNumber, + Name: validUser.Name, + CreatedAt: time.Now().Add(-time.Second), + UpdatedAt: time.Now().Add(-time.Second), + } + err = uv.UserValidation(user) + assert.NoError(t, err) + }) +} diff --git a/cspell.json b/cspell.json index e2bd3c9..d6d298e 100644 --- a/cspell.json +++ b/cspell.json @@ -14,17 +14,28 @@ "words": [ "wontfix", "venv", + "usecase", "usbutils", + "Unexited", + "stretchr", + "sqlmock", "rdwr", "pyproject", "pylint", + "ozzo", + "Nooop", + "mysqld", + "mysqladmin", "mypy", + "mockdb", "labstack", "isort", + "healthcheck", "gorm", "gopls", "golangci", "goimports", - "dotenv" + "dotenv", + "Dialector" ] } diff --git a/docker-compose.yml b/docker-compose.yml index 20d3cc7..50361b5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,13 +7,15 @@ services: dockerfile: Dockerfile target: development volumes: - - ./api/src:/app/src - - ./api/.golangci.yml:/app/.golangci.yml + - ./api/src:/app ports : - "8080:8080" env_file: - ./api/.env.api - tty: true + depends_on: + mysql: + condition: service_healthy + command: ["go","run","main.go"] mysql: container_name: nfc-entry-management-mysql @@ -25,6 +27,12 @@ services: - "3306:3306" env_file: - ./mysql/.env.mysql + healthcheck: + test: ["CMD-SHELL","mysqladmin ping -h localhost -u root -p$$MYSQL_ROOT_PASSWORD | grep 'mysqld is alive'"] + interval: 10s + timeout: 3s + retries: 3 + start_period: 30s nfc_reader: container_name: nfc-entry-management-nfc-reader diff --git a/mysql/.env.mysql.example b/mysql/.env.mysql.example index 35d0049..a161a3e 100644 --- a/mysql/.env.mysql.example +++ b/mysql/.env.mysql.example @@ -2,5 +2,6 @@ TZ=Asia/Tokyo MYSQL_USER=admin MYSQL_ROOT_PASSWORD=rootpassword MYSQL_PASSWORD=password +MYSQL_HOST=mysql MYSQL_DATABASE=nfc-entry-management-db LANG=ja_JP.UTF-8 diff --git a/nfc_reader/.env.nfc_reader.example b/nfc_reader/.env.nfc_reader.example index 6e94dc6..988e811 100644 --- a/nfc_reader/.env.nfc_reader.example +++ b/nfc_reader/.env.nfc_reader.example @@ -1,4 +1,5 @@ NFC_SYSTEM_CODE=0xFE00 -NFC_SERVICE_CODE=0x1A8B NFC_STUDENT_NUM_BLOCK_CODE=0 +NFC_SERVICE_CODE=0x1A8B NFC_NAME_BLOCK_CODE=1 +API_URL=http://api:8080 diff --git a/nfc_reader/requirements-dev.txt b/nfc_reader/requirements-dev.txt index f7748ef..16c907c 100644 --- a/nfc_reader/requirements-dev.txt +++ b/nfc_reader/requirements-dev.txt @@ -1,7 +1,10 @@ astroid==3.0.2 black==24.1.1 +certifi==2024.2.2 +charset-normalizer==3.3.2 click==8.1.7 dill==0.3.8 +idna==3.6 isort==5.13.2 libusb1==3.1.0 mccabe==0.7.0 @@ -16,5 +19,10 @@ pyDes==2.0.1 pylint==3.0.3 pyserial==3.5 python-dotenv==1.0.1 +requests==2.31.0 +setuptools==69.0.3 tomlkit==0.12.3 +types-requests==2.31.0.20240125 typing_extensions==4.9.0 +urllib3==2.2.0 +wheel==0.42.0 diff --git a/nfc_reader/requirements.txt b/nfc_reader/requirements.txt index 8db34f8..27db39d 100644 --- a/nfc_reader/requirements.txt +++ b/nfc_reader/requirements.txt @@ -1,6 +1,13 @@ +certifi==2024.2.2 +charset-normalizer==3.3.2 +idna==3.6 libusb1==3.1.0 ndeflib==0.3.3 nfcpy==1.0.4 pyDes==2.0.1 pyserial==3.5 python-dotenv==1.0.1 +requests==2.31.0 +setuptools==69.0.3 +urllib3==2.2.0 +wheel==0.42.0 diff --git a/nfc_reader/src/main.py b/nfc_reader/src/main.py index b6fb4cd..fbc0fe1 100644 --- a/nfc_reader/src/main.py +++ b/nfc_reader/src/main.py @@ -3,9 +3,12 @@ """ import os +import threading +import time from dataclasses import dataclass import nfc +import requests from dotenv import load_dotenv @@ -56,7 +59,7 @@ def read_nfc_tag(tag: nfc.tag.Tag, config: Configuration) -> NfcTagInfo: student_num = tag.read_without_encryption([sc], [bc]) if isinstance(student_num, str): student_num = student_num.encode("shift_jis") - student_num = student_num.decode("shift_jis") + student_num = student_num.decode("shift_jis").strip("\x00").strip("\x001") print("student_number : " + str(student_num)) # name @@ -64,12 +67,33 @@ def read_nfc_tag(tag: nfc.tag.Tag, config: Configuration) -> NfcTagInfo: name = tag.read_without_encryption([sc], [bc]) if isinstance(name, str): name = name.encode("shift_jis") - name = name.decode("shift_jis") + name = name.decode("shift_jis").strip("\x00").strip("\x001") print("name : " + str(name)) return NfcTagInfo(idm, pmm, config.nfc_system_code, student_num, name) +def send_request_to_api(nfc_tag_info: NfcTagInfo, unix_timestamp: float) -> None: + """ + NFCタグの情報とUnixタイムスタンプをAPIに送信します。 + + Parameters: + nfc_tag_info (NfcTagInfo): NFCタグの情報を含むオブジェクト + unix_timestamp (float): Unixタイムスタンプ(1970年1月1日からの経過秒数) + """ + url = os.environ["API_URL"] + headers = {"Content-Type": "application/json"} + body = { + "student_number": int(nfc_tag_info.student_num), + "name": nfc_tag_info.name, + "timestamp": unix_timestamp, + } + + response = requests.post(url, json=body, headers=headers, timeout=5) + print(response.status_code) + print(response.text) + + def on_connect(tag: nfc.tag.Tag) -> bool: """ NFCタグが接続されたときに呼び出される関数。 @@ -90,6 +114,12 @@ def on_connect(tag: nfc.tag.Tag) -> bool: nfc_tag_info = read_nfc_tag(tag, configuration) print(nfc_tag_info) + current_unix_time = time.time() + + thread = threading.Thread( + target=send_request_to_api, args=(nfc_tag_info, current_unix_time) + ) + thread.start() return True