diff --git a/.air.conf b/.air.conf index b54727e..db0fd26 100644 --- a/.air.conf +++ b/.air.conf @@ -11,7 +11,7 @@ cmd = "go build -o ./tmp/main ./cmd" # Binary file yields from `cmd`. bin = "tmp/main" # Customize binary. -full_bin = "APP_ENV=dev APP_USER=air ./tmp/main" +full_bin = "APP_ENV=development APP_USER=air ./tmp/main" # Watch these filename extensions. include_ext = ["go"] # Ignore these filename extNensions or directories. diff --git a/.env.example b/.env.example index 619873c..ee205ae 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,10 @@ +APP_ENV=development PORT=8080 PG_USER=postgres PG_PASSWORD=postgres PG_HOST=db PG_PORT=5432 -PG_DB=timecodes_db + +POSTGRES_DB=timecodes_development GOOGLE_API_KEY= diff --git a/.env.test b/.env.test new file mode 100644 index 0000000..bbdcd55 --- /dev/null +++ b/.env.test @@ -0,0 +1,10 @@ +APP_ENV=test +PORT=8080 +PG_USER=postgres +PG_PASSWORD=postgres +PG_HOST=db +PG_PORT=5432 + +POSTGRES_DB=timecodes_test + +GOOGLE_API_KEY= diff --git a/.github/workflows/pr_checks.yml b/.github/workflows/pr_checks.yml index 886dd53..c1cba71 100644 --- a/.github/workflows/pr_checks.yml +++ b/.github/workflows/pr_checks.yml @@ -15,17 +15,21 @@ jobs: steps: - name: Checkout current branch uses: actions/checkout@master - - name: Cache - uses: actions/cache@v1 + - name: docker-compose version + run: docker-compose --version + - name: Build docker image + run: | + cp .env.example .env + docker-compose build app_test + - name: Run tests + run: docker-compose run app_test go test ./... -covermode=count -coverprofile=coverage.out + - name: Convert coverage to lcov + uses: jandelgado/gcov2lcov-action@v1.0.2 with: - path: ~/go/pkg/mod - key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} - restore-keys: | - ${{ runner.os }}-go- - - uses: actions/setup-go@v1 + infile: coverage.out + outfile: coverage.lcov + - name: Coveralls + uses: coverallsapp/github-action@master with: - go-version: '1.13' - - name: Run go test - run: | - go mod download - go test ./... -cover + github-token: ${{ secrets.github_token }} + path-to-lcov: coverage.lcov diff --git a/README.md b/README.md index 5d0ed69..c289091 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,27 @@ # Backend for Timecodes Chrome extension +[![Coverage Status](https://coveralls.io/repos/github/letscode-io/timecodes-api/badge.svg)](https://coveralls.io/github/letscode-io/timecodes-api) + # Development ```bash $ docker-compose up --build app_dev ``` +# Run the whole test suite + +```bash +$ docker-compose build app_test +$ docker-compose run app_test go test ./... -cover +``` + +# Run a single test + +```bash +$ docker-compose build app_test +$ docker-compose run app_test go test -run Test%TEST_FUNCTION_NAME% +``` + # Build for using in production ```bash diff --git a/cmd/auth_middleware.go b/cmd/auth_middleware.go index 8d224ac..19538fe 100644 --- a/cmd/auth_middleware.go +++ b/cmd/auth_middleware.go @@ -12,21 +12,31 @@ var authTokenRegExp = regexp.MustCompile(`Bearer (\S+$)`) type CurrentUserKey struct{} -func authMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - token := getAuthToken(r.Header.Get("Authorization")) - - userInfo, err := googleAPI.FetchUserInfo(token) - if err != nil { - http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) - return - } - - ctx := context.WithValue(r.Context(), CurrentUserKey{}, findOrCreateUser(userInfo)) - r = r.WithContext(ctx) - - next.ServeHTTP(w, r) - }) +func authMiddleware(c *Container) (mw func(http.Handler) http.Handler) { + mw = func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + currentUser := getCurrentUser(r) + if currentUser != nil { + next.ServeHTTP(w, r) + return + } + + token := getAuthToken(r.Header.Get("Authorization")) + + userInfo, err := googleAPI.FetchUserInfo(token) + if err != nil { + http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) + return + } + + user := c.UserRepository.FindOrCreateByGoogleInfo(userInfo) + ctx := context.WithValue(r.Context(), CurrentUserKey{}, user) + r = r.WithContext(ctx) + + next.ServeHTTP(w, r) + }) + } + return } func getAuthToken(authorizationHeader string) string { @@ -40,13 +50,3 @@ func getAuthToken(authorizationHeader string) string { return string(token) } - -func findOrCreateUser(userInfo *googleAPI.UserInfo) *User { - currentUser := &User{} - - db.Where(User{GoogleID: userInfo.Id}). - Assign(User{Email: userInfo.Email, PictureURL: userInfo.Picture}). - FirstOrCreate(¤tUser) - - return currentUser -} diff --git a/cmd/container.go b/cmd/container.go new file mode 100644 index 0000000..2253672 --- /dev/null +++ b/cmd/container.go @@ -0,0 +1,13 @@ +package main + +import ( + youtubeapi "timecodes/cmd/youtube_api" +) + +type Container struct { + TimecodeLikeRepository TimecodeLikeRepository + TimecodeRepository TimecodeRepository + UserRepository UserRepository + + YoutubeAPI *youtubeapi.Service +} diff --git a/cmd/db.go b/cmd/db.go index 3f40a38..7b7ccaf 100644 --- a/cmd/db.go +++ b/cmd/db.go @@ -10,47 +10,67 @@ import ( _ "github.com/jinzhu/gorm/dialects/postgres" ) -func initDB() { - var err error +func getDbName() string { + return fmt.Sprintf("timecodes_%s", os.Getenv("APP_ENV")) +} - dsn := url.URL{ - User: url.UserPassword(os.Getenv("PG_USER"), os.Getenv("PG_PASSWORD")), - Scheme: "postgres", - Host: fmt.Sprintf("%s:%s", os.Getenv("PG_HOST"), os.Getenv("PG_PORT")), - Path: os.Getenv("PG_DB"), - RawQuery: (&url.Values{"sslmode": []string{"disable"}}).Encode(), - } +func getEnvDSN() url.URL { + return getDsn(getDbName()) +} - db, err = gorm.Open("postgres", dsn.String()) +func initDB() *gorm.DB { + envDsn := getEnvDSN() + + db, err := gorm.Open("postgres", envDsn.String()) if err != nil { - log.Println("Failed to connect to database") - panic(err) + dbName := getDbName() + db = createDatabase(dbName) } - log.Println("DB connection has been established.") + log.Println("Connection to the database has been established.") + + return db } -func createTables() { - if db.HasTable(&Timecode{}) { - return +func createDatabase(dbName string) *gorm.DB { + db := getDefaultConnection() + + err := db.Exec(fmt.Sprintf("CREATE DATABASE %s;", dbName)).Error + if err != nil { + handleDBConnectionError(err) } - err := db.CreateTable(&Timecode{}) + dsn := getDsn(dbName) + + db, err = gorm.Open("postgres", dsn.String()) if err != nil { - log.Println("Table already exists") + handleDBConnectionError(err) } + + return db } -func runMigrations() { - db.AutoMigrate(&Timecode{}) - db.Model(&Timecode{}).AddUniqueIndex( - "idx_timecodes_seconds_text_video_id", - "seconds", "description", "video_id", - ) - db.AutoMigrate(&User{}) - db.AutoMigrate(&TimecodeLike{}) - db.Model(&TimecodeLike{}).AddUniqueIndex( - "idx_timecodes_likes_user_id_timecode_id_video_id", - "user_id", "timecode_id", - ) +func getDefaultConnection() *gorm.DB { + defaultDsn := getDsn("postgres") + db, err := gorm.Open("postgres", defaultDsn.String()) + if err != nil { + handleDBConnectionError(err) + } + + return db +} + +func handleDBConnectionError(err error) { + log.Println("Unable to connect to db") + panic(err) +} + +func getDsn(path string) url.URL { + return url.URL{ + User: url.UserPassword(os.Getenv("PG_USER"), os.Getenv("PG_PASSWORD")), + Scheme: "postgres", + Host: fmt.Sprintf("%s:%s", os.Getenv("PG_HOST"), os.Getenv("PG_PORT")), + Path: path, + RawQuery: (&url.Values{"sslmode": []string{"disable"}}).Encode(), + } } diff --git a/cmd/db_migrations.go b/cmd/db_migrations.go new file mode 100644 index 0000000..ce7fbec --- /dev/null +++ b/cmd/db_migrations.go @@ -0,0 +1,35 @@ +package main + +import "github.com/jinzhu/gorm" + +func runMigrations(db *gorm.DB) { + applyTimecodesMigrations(db) + applyUsersMigrations(db) + applyTimecodeLikesMigrations(db) +} + +func applyTimecodesMigrations(db *gorm.DB) { + db.AutoMigrate(&Timecode{}) + db.Model(&Timecode{}).AddUniqueIndex( + "idx_timecodes_seconds_text_video_id", + "seconds", "description", "video_id", + ) + db.Exec(` + ALTER TABLE timecodes + ADD CONSTRAINT description_min_length CHECK (length(description) >= 1); + `) +} + +func applyUsersMigrations(db *gorm.DB) { + db.AutoMigrate(&User{}) +} + +func applyTimecodeLikesMigrations(db *gorm.DB) { + db.AutoMigrate(&TimecodeLike{}) + db.Model(&TimecodeLike{}).AddUniqueIndex( + "idx_timecodes_likes_user_id_timecode_id_video_id", + "user_id", "timecode_id", + ) + db.Model(&TimecodeLike{}).AddForeignKey("timecode_id", "timecodes(id)", "RESTRICT", "RESTRICT") + db.Model(&TimecodeLike{}).AddForeignKey("user_id", "users(id)", "RESTRICT", "RESTRICT") +} diff --git a/cmd/google_api/google_api.go b/cmd/google_api/google_api.go index c6f4275..f5d68a8 100644 --- a/cmd/google_api/google_api.go +++ b/cmd/google_api/google_api.go @@ -11,11 +11,21 @@ import ( const userInfoHost = "https://www.googleapis.com/oauth2/v2/userinfo" type UserInfo struct { - Id string `json:"id"` + ID string `json:"id"` Picture string `json:"picture"` Email string `json:"email"` } +type APIError struct { + ErrorData struct { + Message string `json:"message"` + } `json:"error"` +} + +func (e *APIError) Error() string { + return e.ErrorData.Message +} + func FetchUserInfo(accessToken string) (userInfo *UserInfo, err error) { if len(accessToken) == 0 { return nil, errors.New("accessToken cannot be empty") @@ -35,7 +45,13 @@ func FetchUserInfo(accessToken string) (userInfo *UserInfo, err error) { switch response.StatusCode { case 401: - userInfo, err = nil, errors.New(string(contents)) + apiError := &APIError{} + unmarshalErr := json.Unmarshal(contents, apiError) + if unmarshalErr != nil { + return nil, unmarshalErr + } + + err = apiError default: userInfo = &UserInfo{} err = nil diff --git a/cmd/google_api/google_api_test.go b/cmd/google_api/google_api_test.go new file mode 100644 index 0000000..c92ad04 --- /dev/null +++ b/cmd/google_api/google_api_test.go @@ -0,0 +1,119 @@ +package googleapi + +import ( + "errors" + "net/http" + "os" + "testing" + + "github.com/jarcoal/httpmock" + "github.com/stretchr/testify/assert" +) + +type errReader int + +func (errReader) Read(p []byte) (n int, err error) { + return 0, errors.New("reader error") +} + +func (errReader) Close() error { + return nil +} + +func TestMain(m *testing.M) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + registerMockResponders() + + os.Exit(m.Run()) +} + +func registerMockResponders() { + httpmock.RegisterResponder( + "GET", "https://www.googleapis.com/oauth2/v2/userinfo?access_token=tokenWithInvalidData", + httpmock.ResponderFromResponse( + &http.Response{ + Status: "200", + StatusCode: 200, + Body: errReader(0), + Header: http.Header{}, + ContentLength: 1, + }, + ), + ) + + unauthorizedError := ` + { + "error": { + "code": 401, + "message": "Request is missing required authentication credential.", + "status": "UNAUTHENTICATED" + } + } + ` + httpmock.RegisterResponder( + "GET", "https://www.googleapis.com/oauth2/v2/userinfo?access_token=invalidToken", + httpmock.NewStringResponder(401, unauthorizedError), + ) + + userInfoResponse := ` + { + "id": "92752", + "email": "better_call_your_local_dealer@gmail.com", + "verified_email": true, + "picture": "https://lh3.googleusercontent.com/a-/0f78ce08-876e-4ffa-a227-84f52a69063f" + } + ` + httpmock.RegisterResponder( + "GET", "https://www.googleapis.com/oauth2/v2/userinfo?access_token=validToken", + httpmock.NewStringResponder(200, userInfoResponse), + ) +} + +func TestFetchUserInfo(t *testing.T) { + t.Run("when given accessToken is empty", func(t *testing.T) { + token := "" + userInfo, err := FetchUserInfo(token) + + assert.Nil(t, userInfo) + assert.EqualError(t, err, "accessToken cannot be empty") + }) + + t.Run("when http client error occurs", func(t *testing.T) { + token := "tokenWithClientError" + userInfo, err := FetchUserInfo(token) + + assert.Nil(t, userInfo) + assert.EqualError( + t, + err, + `failed getting user info: Get "https://www.googleapis.com/oauth2/v2/userinfo?access_token=tokenWithClientError": no responder found`, + ) + }) + + t.Run("when response body contains invalid data", func(t *testing.T) { + token := "tokenWithInvalidData" + userInfo, err := FetchUserInfo(token) + + assert.Nil(t, userInfo) + assert.EqualError(t, err, "failed reading response body: reader error") + }) + + t.Run("when given accessToken return unauthorized response", func(t *testing.T) { + token := "invalidToken" + userInfo, err := FetchUserInfo(token) + + assert.Nil(t, userInfo) + assert.EqualError(t, err, "Request is missing required authentication credential.") + }) + + t.Run("when valid access token given", func(t *testing.T) { + token := "validToken" + userInfo, err := FetchUserInfo(token) + + assert.NotNil(t, userInfo) + assert.Equal(t, "92752", userInfo.ID) + assert.Nil(t, err) + }) +} diff --git a/cmd/main.go b/cmd/main.go index 7e9277c..59450a6 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -1,27 +1,30 @@ package main import ( - "github.com/jinzhu/gorm" + "log" + "net/http" youtubeAPI "timecodes/cmd/youtube_api" ) -var ( - db *gorm.DB - youtubeService *youtubeAPI.Service -) +func main() { + db := initDB() + runMigrations(db) -func init() { - initDB() - createTables() - runMigrations() - initYoutubeService() -} + ytService, err := youtubeAPI.New() + if err != nil { + log.Fatal(err) + } -func initYoutubeService() { - youtubeService = youtubeAPI.New() -} + container := &Container{ + UserRepository: &DBUserRepository{DB: db}, + TimecodeRepository: &DBTimecodeRepository{DB: db}, + TimecodeLikeRepository: &DBTimecodeLikeRepository{DB: db}, + YoutubeAPI: ytService, + } -func main() { - startHttpServer() + router := createRouter(container) + + log.Println("Starting development server at http://127.0.0.1:8080/") + log.Fatal(http.ListenAndServe(":8080", router)) } diff --git a/cmd/main_test.go b/cmd/main_test.go new file mode 100644 index 0000000..299b75b --- /dev/null +++ b/cmd/main_test.go @@ -0,0 +1,39 @@ +package main + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/khaiql/dbcleaner/engine" + "gopkg.in/khaiql/dbcleaner.v2" +) + +var Cleaner = dbcleaner.New() +var TestDB = initDB() + +func TestMain(m *testing.M) { + dsn := getEnvDSN() + pg := engine.NewPostgresEngine(dsn.String()) + Cleaner.SetEngine(pg) + + runMigrations(TestDB) + defer TestDB.Close() + + os.Exit(m.Run()) +} + +func executeRequest(t *testing.T, router http.Handler, req *http.Request, user *User) *httptest.ResponseRecorder { + t.Helper() + + req.Header.Set("Content-Type", "application/json") + ctx := context.WithValue(context.Background(), CurrentUserKey{}, user) + req = req.WithContext(ctx) + + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + + return rr +} diff --git a/cmd/models.go b/cmd/models.go deleted file mode 100644 index 5886583..0000000 --- a/cmd/models.go +++ /dev/null @@ -1,25 +0,0 @@ -package main - -import "github.com/jinzhu/gorm" - -// Timecode struct -type Timecode struct { - gorm.Model - Description string `json:"description"` - Seconds int `json:"seconds" gorm:"not null"` - VideoID string `json:"videoId" gorm:"not null;index"` - Likes []TimecodeLike `json:"likes" gorm:"foreignkey:TimecodeID"` -} - -type User struct { - gorm.Model - Email string - GoogleID string - PictureURL string -} - -type TimecodeLike struct { - gorm.Model - TimecodeID uint `json:"timecodeId" gorm:"not null"` - UserID uint `json:"userId" gorm:"not null"` -} diff --git a/cmd/router.go b/cmd/router.go index f6e03dc..56e61ae 100644 --- a/cmd/router.go +++ b/cmd/router.go @@ -2,43 +2,49 @@ package main import ( "fmt" - "log" "net/http" "github.com/gorilla/mux" "github.com/rs/cors" ) -func startHttpServer() { - log.Println("Starting development server at http://127.0.0.1:8080/") +type Handler struct { + *Container + H func(c *Container, w http.ResponseWriter, r *http.Request) +} + +// ServeHTTP allows our Handler type to satisfy http.Handler. +func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + h.H(h.Container, w, r) +} +func createRouter(container *Container) http.Handler { router := mux.NewRouter().StrictSlash(true) router.Use(commonMiddleware) // public router.HandleFunc("/", handleHome) - router.HandleFunc("/timecodes/{videoId}", handleGetTimecodes) + router.Handle("/timecodes/{videoId}", Handler{container, handleGetTimecodes}) // auth auth := router.PathPrefix("/auth").Subrouter() - auth.Use(authMiddleware) + auth.Use(authMiddleware(container)) auth.HandleFunc("/login", handleLogin) - auth.HandleFunc("/timecodes", handleCreateTimecode).Methods(http.MethodPost) - auth.HandleFunc("/timecodes/{videoId}", handleGetTimecodes) + auth.Handle("/timecodes", Handler{container, handleCreateTimecode}).Methods(http.MethodPost) + auth.Handle("/timecodes/{videoId}", Handler{container, handleGetTimecodes}) - auth.HandleFunc("/timecode_likes", handleCreateTimecodeLike).Methods(http.MethodPost) - auth.HandleFunc("/timecode_likes", handleDeleteTimecodeLike).Methods(http.MethodDelete) + auth.Handle("/timecode_likes", Handler{container, handleCreateTimecodeLike}).Methods(http.MethodPost) + auth.Handle("/timecode_likes", Handler{container, handleDeleteTimecodeLike}).Methods(http.MethodDelete) - handler := cors.Default().Handler(router) - - log.Fatal(http.ListenAndServe(":8080", handler)) + return cors.Default().Handler(router) } func commonMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Add("Content-Type", "application/json") + next.ServeHTTP(w, r) }) } diff --git a/cmd/timecode_like_repository.go b/cmd/timecode_like_repository.go new file mode 100644 index 0000000..6f7878f --- /dev/null +++ b/cmd/timecode_like_repository.go @@ -0,0 +1,39 @@ +package main + +import "github.com/jinzhu/gorm" + +type TimecodeLike struct { + gorm.Model + TimecodeID uint `json:"timecodeId" gorm:"not null"` + UserID uint `json:"userId" gorm:"not null"` +} + +type TimecodeLikeRepository interface { + Create(*TimecodeLike, uint) (*TimecodeLike, error) + Delete(*TimecodeLike, uint) (*TimecodeLike, error) +} + +type DBTimecodeLikeRepository struct { + TimecodeLikeRepository + + DB *gorm.DB +} + +func (repo *DBTimecodeLikeRepository) Create(timecodeLike *TimecodeLike, userID uint) (*TimecodeLike, error) { + timecodeLike.UserID = userID + + err := repo.DB.Create(timecodeLike).Error + + return timecodeLike, err +} + +func (repo *DBTimecodeLikeRepository) Delete(timecodeLike *TimecodeLike, userID uint) (*TimecodeLike, error) { + err := repo.DB.Where(&TimecodeLike{UserID: userID, TimecodeID: timecodeLike.TimecodeID}).First(timecodeLike).Error + if err != nil { + return nil, err + } + + repo.DB.Unscoped().Delete(timecodeLike) + + return timecodeLike, nil +} diff --git a/cmd/timecode_like_repository_test.go b/cmd/timecode_like_repository_test.go new file mode 100644 index 0000000..aa7a23f --- /dev/null +++ b/cmd/timecode_like_repository_test.go @@ -0,0 +1,102 @@ +package main + +import ( + "testing" + + "github.com/jinzhu/gorm" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" +) + +type TimecodeLikeRepositorySuite struct { + suite.Suite + DB *gorm.DB + Repo *DBTimecodeLikeRepository +} + +func (suite *TimecodeLikeRepositorySuite) SetupSuite() { + suite.DB = TestDB + suite.Repo = &DBTimecodeLikeRepository{DB: TestDB} +} + +func (suite *TimecodeLikeRepositorySuite) SetupTest() { + for _, table := range []string{ + "timecode_likes", + "timecodes", + "users", + } { + Cleaner.Acquire(table) + } +} + +func (suite *TimecodeLikeRepositorySuite) TearDownTest() { + for _, table := range []string{ + "timecode_likes", + "timecodes", + "users", + } { + Cleaner.Clean(table) + } +} + +func TestTimecodeLikeRepositorySuite(t *testing.T) { + suite.Run(t, new(TimecodeLikeRepositorySuite)) +} + +func (suite *TimecodeLikeRepositorySuite) TestDBTimecodeLikeRepository_Create() { + st := suite.T() + + st.Run("when valid parameters given", func(t *testing.T) { + user := &User{Email: "user1@example.com"} + timecode := &Timecode{VideoID: "video-id-1", Description: "test"} + suite.DB.Create(user) + suite.DB.Create(timecode) + + timecodeLike := &TimecodeLike{TimecodeID: timecode.ID} + timecodeLike, err := suite.Repo.Create(timecodeLike, user.ID) + + assert.Equal(t, user.ID, timecodeLike.UserID) + assert.Equal(t, timecode.ID, timecodeLike.TimecodeID) + assert.Nil(t, err) + }) + + st.Run("when invalid parameters given", func(t *testing.T) { + user := &User{Email: "user2@example.com"} + suite.DB.Create(user) + + timecodeLike := &TimecodeLike{TimecodeID: uint(1984)} + timecodeLike, err := suite.Repo.Create(timecodeLike, user.ID) + + assert.True(t, suite.DB.NewRecord(timecodeLike)) + assert.EqualError( + t, err, + `pq: insert or update on table "timecode_likes" violates foreign key constraint "timecode_likes_timecode_id_timecodes_id_foreign"`) + }) +} + +func (suite *TimecodeLikeRepositorySuite) TestDBTimecodeLikeRepository_Delete() { + st := suite.T() + + st.Run("when record exists", func(t *testing.T) { + user := &User{Email: "user3@example.com"} + timecode := &Timecode{VideoID: "video-id-2", Description: "test"} + suite.DB.Create(user) + suite.DB.Create(timecode) + timecodeLike := &TimecodeLike{TimecodeID: timecode.ID, UserID: user.ID} + suite.DB.Create(timecodeLike) + + timecodeLike, err := suite.Repo.Delete(timecodeLike, user.ID) + + assert.True(t, suite.DB.First(timecodeLike).RecordNotFound()) + assert.Nil(t, err) + }) + + st.Run("when record doesn't exist", func(t *testing.T) { + timecodeLike := &TimecodeLike{TimecodeID: 4, UserID: 5} + + timecodeLike, err := suite.Repo.Delete(timecodeLike, 6) + + assert.Nil(t, timecodeLike) + assert.EqualError(t, err, "record not found") + }) +} diff --git a/cmd/timecode_likes_controller.go b/cmd/timecode_likes_controller.go index 0bcf59c..0406f0a 100644 --- a/cmd/timecode_likes_controller.go +++ b/cmd/timecode_likes_controller.go @@ -8,49 +8,50 @@ import ( ) // POST /timecode_likes -func handleCreateTimecodeLike(w http.ResponseWriter, r *http.Request) { +func handleCreateTimecodeLike(c *Container, w http.ResponseWriter, r *http.Request) { currentUser := getCurrentUser(r) - like := &TimecodeLike{UserID: currentUser.ID} + like := &TimecodeLike{} reqBody, _ := ioutil.ReadAll(r.Body) err := json.Unmarshal(reqBody, like) if err != nil { log.Println(err) + + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) return } - err = db.Create(like).Error + _, err = c.TimecodeLikeRepository.Create(like, currentUser.ID) if err != nil { + w.WriteHeader(http.StatusUnprocessableEntity) json.NewEncoder(w).Encode(err) } else { + w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(like) } } // DELETE /timecode_likes -func handleDeleteTimecodeLike(w http.ResponseWriter, r *http.Request) { +func handleDeleteTimecodeLike(c *Container, w http.ResponseWriter, r *http.Request) { currentUser := getCurrentUser(r) - likeParams := &TimecodeLike{} - like := &TimecodeLike{} + timecodeLike := &TimecodeLike{} reqBody, _ := ioutil.ReadAll(r.Body) - err := json.Unmarshal(reqBody, likeParams) + err := json.Unmarshal(reqBody, timecodeLike) if err != nil { log.Println(err) - return - } - err = db.Where(&TimecodeLike{UserID: currentUser.ID, TimecodeID: likeParams.TimecodeID}).First(like).Error - if err != nil { - json.NewEncoder(w).Encode(err) + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) return } - err = db.Unscoped().Delete(like).Error + _, err = c.TimecodeLikeRepository.Delete(timecodeLike, currentUser.ID) if err != nil { + w.WriteHeader(http.StatusUnprocessableEntity) json.NewEncoder(w).Encode(err) } else { - json.NewEncoder(w).Encode(like) + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(timecodeLike) } } diff --git a/cmd/timecode_likes_controller_test.go b/cmd/timecode_likes_controller_test.go new file mode 100644 index 0000000..5a4dfce --- /dev/null +++ b/cmd/timecode_likes_controller_test.go @@ -0,0 +1,148 @@ +package main + +import ( + "bytes" + "errors" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +const invalidTimecodeID = 1984 + +var invalidJSON = []byte("Invalid JSON") + +var mockTKRepo = &MockTimecodeLikeRepository{} +var container = &Container{ + TimecodeLikeRepository: mockTKRepo, +} +var timecodeLikeRouter = createRouter(container) + +type MockTimecodeLikeRepository struct { + mock.Mock +} + +func (mr *MockTimecodeLikeRepository) Create(like *TimecodeLike, userID uint) (*TimecodeLike, error) { + args := mr.Called(like, userID) + err := args.Error(1) + + like.UserID = userID + + if like.TimecodeID == invalidTimecodeID { + return nil, err + } + + _ = args.Get(0).(*TimecodeLike) + + return like, nil +} + +func (mr *MockTimecodeLikeRepository) Delete(like *TimecodeLike, userID uint) (*TimecodeLike, error) { + args := mr.Called(like, userID) + err := args.Error(1) + + like.UserID = userID + + if like.TimecodeID == invalidTimecodeID { + return nil, err + } + + _ = args.Get(0).(*TimecodeLike) + + return like, nil +} + +func Test_handleCreateTimecodeLike(t *testing.T) { + url := "/auth/timecode_likes" + + t.Run("when creation is successful", func(t *testing.T) { + currentUser := &User{} + currentUser.ID = 1 + + like := &TimecodeLike{TimecodeID: 5} + mockTKRepo.On("Create", like, currentUser.ID).Return(like, nil) + + params := []byte(`{ "timecodeId": 5 }`) + req, _ := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(params)) + + response := executeRequest(t, timecodeLikeRouter, req, currentUser) + + mockTKRepo.AssertExpectations(t) + assert.Equal(t, http.StatusCreated, response.Code) + }) + + t.Run("when creation has been failed", func(t *testing.T) { + currentUser := &User{} + currentUser.ID = 1 + + like := &TimecodeLike{TimecodeID: invalidTimecodeID} + mockTKRepo.On("Create", like, currentUser.ID).Return(nil, errors.New("")) + + params := []byte(fmt.Sprintf(`{ "timecodeId": %d }`, invalidTimecodeID)) + req, _ := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(params)) + + response := executeRequest(t, timecodeLikeRouter, req, currentUser) + + mockTKRepo.AssertExpectations(t) + assert.Equal(t, http.StatusUnprocessableEntity, response.Code) + }) + + t.Run("when invalid request params has been given", func(t *testing.T) { + currentUser := &User{} + + req, _ := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(invalidJSON)) + + response := executeRequest(t, timecodeLikeRouter, req, currentUser) + + assert.Equal(t, http.StatusBadRequest, response.Code) + }) +} + +func Test_handleDeleteTimecodeLike(t *testing.T) { + url := "/auth/timecode_likes" + + t.Run("when deletion is successful", func(t *testing.T) { + currentUser := &User{} + currentUser.ID = 1 + + like := &TimecodeLike{TimecodeID: 5} + mockTKRepo.On("Delete", like, currentUser.ID).Return(like, nil) + + params := []byte(`{ "timecodeId": 5 }`) + req, _ := http.NewRequest(http.MethodDelete, url, bytes.NewBuffer(params)) + + response := executeRequest(t, timecodeLikeRouter, req, currentUser) + + mockTKRepo.AssertExpectations(t) + assert.Equal(t, http.StatusOK, response.Code) + }) + + t.Run("when deletion has been failed", func(t *testing.T) { + currentUser := &User{} + currentUser.ID = 1 + + like := &TimecodeLike{TimecodeID: invalidTimecodeID} + mockTKRepo.On("Delete", like, currentUser.ID).Return(nil, errors.New("")) + + params := []byte(fmt.Sprintf(`{ "timecodeId": %d }`, invalidTimecodeID)) + req, _ := http.NewRequest(http.MethodDelete, url, bytes.NewBuffer(params)) + + response := executeRequest(t, timecodeLikeRouter, req, currentUser) + + mockTKRepo.AssertExpectations(t) + assert.Equal(t, http.StatusUnprocessableEntity, response.Code) + }) + + t.Run("when invalid request params has been given", func(t *testing.T) { + currentUser := &User{} + + req, _ := http.NewRequest(http.MethodDelete, url, bytes.NewBuffer(invalidJSON)) + + response := executeRequest(t, timecodeLikeRouter, req, currentUser) + + assert.Equal(t, http.StatusBadRequest, response.Code) + }) +} diff --git a/cmd/timecode_repository.go b/cmd/timecode_repository.go new file mode 100644 index 0000000..4aa54f3 --- /dev/null +++ b/cmd/timecode_repository.go @@ -0,0 +1,70 @@ +package main + +import ( + "strconv" + timecodeParser "timecodes/cmd/timecode_parser" + + "github.com/jinzhu/gorm" +) + +// Timecode represents timecode model +type Timecode struct { + gorm.Model + Description string `json:"description" gorm:"not null;default:null"` + Seconds int `json:"seconds" gorm:"not null"` + VideoID string `json:"videoId" gorm:"not null;index"` + Likes []TimecodeLike `json:"likes" gorm:"foreignkey:TimecodeID"` +} + +type TimecodeRepository interface { + FindByVideoId(string) *[]*Timecode + Create(*Timecode) (*Timecode, error) + CreateFromParsedCodes([]timecodeParser.ParsedTimeCode, string) *[]*Timecode +} + +type DBTimecodeRepository struct { + TimecodeRepository + + DB *gorm.DB +} + +func (repo *DBTimecodeRepository) FindByVideoId(videoID string) *[]*Timecode { + timecodes := &[]*Timecode{} + + repo.DB.Order("seconds asc"). + Preload("Likes"). + Where(&Timecode{VideoID: videoID}). + Find(timecodes) + + return timecodes +} + +func (repo *DBTimecodeRepository) Create(timecode *Timecode) (*Timecode, error) { + err := repo.DB.Create(timecode).Error + + return timecode, err +} + +func (repo *DBTimecodeRepository) CreateFromParsedCodes(parsedTimecodes []timecodeParser.ParsedTimeCode, videoId string) *[]*Timecode { + seen := make(map[string]struct{}) + + var collection []*Timecode + for _, code := range parsedTimecodes { + key := strconv.Itoa(code.Seconds) + code.Description + if _, ok := seen[key]; ok { + continue + } + + seen[key] = struct{}{} + + timecode := &Timecode{Seconds: code.Seconds, VideoID: videoId, Description: code.Description} + + err := repo.DB.Create(timecode).Error + if err != nil { + continue + } + collection = append(collection, timecode) + } + + return &collection +} diff --git a/cmd/timecode_repository_test.go b/cmd/timecode_repository_test.go new file mode 100644 index 0000000..30acf20 --- /dev/null +++ b/cmd/timecode_repository_test.go @@ -0,0 +1,103 @@ +package main + +import ( + "testing" + timecodeParser "timecodes/cmd/timecode_parser" + + "github.com/jinzhu/gorm" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" +) + +const videoID = "armenian-dram" +const anotherVideoID = "strategist" + +type TimecodeRepositorySuite struct { + suite.Suite + DB *gorm.DB + Repo *DBTimecodeRepository +} + +func (suite *TimecodeRepositorySuite) SetupSuite() { + suite.DB = TestDB + suite.Repo = &DBTimecodeRepository{DB: TestDB} +} + +func (suite *TimecodeRepositorySuite) SetupTest() { + Cleaner.Acquire("timecodes") +} + +func (suite *TimecodeRepositorySuite) TearDownTest() { + Cleaner.Clean("timecodes") +} + +func TestTimecodeRepositorySuite(t *testing.T) { + suite.Run(t, new(TimecodeRepositorySuite)) +} + +func (suite *TimecodeRepositorySuite) TestDBTimecodeRepository_FindByVideoId() { + t := suite.T() + + t.Run("when matching records exist", func(t *testing.T) { + suite.DB.Create(&Timecode{VideoID: videoID, Seconds: 55, Description: "ABC"}) + suite.DB.Create(&Timecode{VideoID: videoID, Seconds: 23, Description: "DEFG"}) + suite.DB.Create(&Timecode{VideoID: anotherVideoID, Seconds: 77, Description: "FGHJ"}) + defer Cleaner.Clean("timecodes") + + timecodes := *suite.Repo.FindByVideoId(videoID) + + assert.Equal(t, 2, len(timecodes)) + assert.Equal(t, 23, timecodes[0].Seconds) + assert.Equal(t, 55, timecodes[1].Seconds) + }) + + t.Run("when there are no matching records", func(t *testing.T) { + suite.DB.Create(&Timecode{VideoID: anotherVideoID, Seconds: 77, Description: "FGHJ"}) + + timecodes := *suite.Repo.FindByVideoId(videoID) + + assert.Equal(t, 0, len(timecodes)) + }) +} + +func (suite *TimecodeRepositorySuite) TestDBTimecodeRepository_Create() { + t := suite.T() + + t.Run("when record has been created", func(t *testing.T) { + timecode, err := suite.Repo.Create(&Timecode{VideoID: videoID, Seconds: 55, Description: "ABC"}) + + assert.Nil(t, err) + assert.NotNil(t, timecode.ID) + assert.Equal(t, videoID, timecode.VideoID) + }) + + t.Run("when db returns an error", func(t *testing.T) { + seconds := 10 + description := "ABC" + + suite.DB.Create(&Timecode{VideoID: videoID, Seconds: seconds, Description: description}) + + timecode, err := suite.Repo.Create(&Timecode{VideoID: videoID, Seconds: seconds, Description: description}) + + assert.True(t, suite.DB.NewRecord(timecode)) + assert.EqualError(t, err, `pq: duplicate key value violates unique constraint "idx_timecodes_seconds_text_video_id"`) + }) +} + +func (suite *TimecodeRepositorySuite) TestDBTimecodeRepository_CreateFromParsedCodes() { + suite.T().Run("when valid parsed codes has been given", func(t *testing.T) { + parsedTimecodes := []timecodeParser.ParsedTimeCode{ + {Seconds: 24, Description: "ABC"}, + {Seconds: 24, Description: "ABC"}, + {Seconds: 56, Description: "DFG"}, + {Seconds: 56, Description: "DFG"}, + {Seconds: 56, Description: ""}, + } + + timecodes := *suite.Repo.CreateFromParsedCodes(parsedTimecodes, videoID) + + assert.Equal(t, 2, len(timecodes)) + assert.Equal(t, 24, timecodes[0].Seconds) + assert.Equal(t, 56, timecodes[1].Seconds) + }) +} diff --git a/cmd/timecodes_controller.go b/cmd/timecodes_controller.go index 3dd010b..98317e4 100644 --- a/cmd/timecodes_controller.go +++ b/cmd/timecodes_controller.go @@ -20,50 +20,40 @@ type TimecodeJSON struct { VideoID string `json:"videoId"` } -// GET /timecodes -func handleGetTimecodes(w http.ResponseWriter, r *http.Request) { +// GET /timecodes/{videoId} +func handleGetTimecodes(c *Container, w http.ResponseWriter, r *http.Request) { currentUser := getCurrentUser(r) - timecodes := &[]*Timecode{} - videoId := mux.Vars(r)["videoId"] + videoID := mux.Vars(r)["videoId"] - err := db.Order("seconds asc"). - Preload("Likes"). - Where(&Timecode{VideoID: videoId}). - Find(timecodes). - Error + timecodes := c.TimecodeRepository.FindByVideoId(videoID) - if err != nil { - json.NewEncoder(w).Encode(err) - } else { - if len(*timecodes) == 0 { - go func() { - parseDescriptionAndCreateAnnotations(videoId) - parseCommentsAndCreateAnnotations(videoId) - }() - } - - timecodeJSONCollection := make([]*TimecodeJSON, 0) - for _, timecode := range *timecodes { - timecodeJSONCollection = append(timecodeJSONCollection, serializeTimecode(timecode, currentUser)) - } + if len(*timecodes) == 0 { + go parseVideoContentAndCreateTimecodes(c, videoID) + } - json.NewEncoder(w).Encode(timecodeJSONCollection) + timecodeJSONCollection := make([]*TimecodeJSON, 0) + for _, timecode := range *timecodes { + timecodeJSONCollection = append(timecodeJSONCollection, serializeTimecode(timecode, currentUser)) } + + json.NewEncoder(w).Encode(timecodeJSONCollection) } // POST /timecodes -func handleCreateTimecode(w http.ResponseWriter, r *http.Request) { +func handleCreateTimecode(c *Container, w http.ResponseWriter, r *http.Request) { currentUser := getCurrentUser(r) timecode := &Timecode{} reqBody, _ := ioutil.ReadAll(r.Body) err := json.Unmarshal(reqBody, timecode) if err != nil { - json.NewEncoder(w).Encode(err) + log.Println(err) + + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) return } - err = db.Create(timecode).Error + _, err = c.TimecodeRepository.Create(timecode) if err != nil { json.NewEncoder(w).Encode(err) } else { @@ -97,47 +87,17 @@ func getLikedByMe(likes []TimecodeLike, userID uint) bool { return false } -func parseDescriptionAndCreateAnnotations(videoId string) { - description := youtubeService.FetchVideoDescription(videoId) +func parseVideoContentAndCreateTimecodes(c *Container, videoID string) { + description := c.YoutubeAPI.FetchVideoDescription(videoID) parsedCodes := timecodeParser.Parse(description) - createTimecodes(parsedCodes, videoId) -} - -func parseCommentsAndCreateAnnotations(videoId string) { - var parsedCodes []timecodeParser.ParsedTimeCode - - comments, err := youtubeService.FetchVideoComments(videoId) - if err != nil { - log.Println(err) - - return - } + comments := c.YoutubeAPI.FetchVideoComments(videoID) for _, comment := range comments { - timeCodes := timecodeParser.Parse(comment.Snippet.TopLevelComment.Snippet.TextOriginal) + timeCodes := timecodeParser.Parse(comment) parsedCodes = append(parsedCodes, timeCodes...) } - createTimecodes(parsedCodes, videoId) -} - -func createTimecodes(parsedTimecodes []timecodeParser.ParsedTimeCode, videoId string) { - seen := make(map[string]struct{}) - - for _, code := range parsedTimecodes { - key := string(code.Seconds) + code.Description - if _, ok := seen[key]; ok { - continue - } - - seen[key] = struct{}{} - - annotation := &Timecode{Seconds: code.Seconds, VideoID: videoId, Description: code.Description} - err := db.Create(annotation).Error - if err != nil { - log.Println(err) - } - } + c.TimecodeRepository.CreateFromParsedCodes(parsedCodes, videoID) } diff --git a/cmd/timecodes_controller_test.go b/cmd/timecodes_controller_test.go new file mode 100644 index 0000000..bf8dd66 --- /dev/null +++ b/cmd/timecodes_controller_test.go @@ -0,0 +1,105 @@ +package main + +import ( + "bytes" + "net/http" + "testing" + timecodeParser "timecodes/cmd/timecode_parser" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +var mockTimecodeRepo = &MockTimecodeRepository{} +var timecodesContainer = &Container{ + TimecodeRepository: mockTimecodeRepo, +} +var timecodesRouter = createRouter(timecodesContainer) + +type MockTimecodeRepository struct { + mock.Mock +} + +func (m *MockTimecodeRepository) FindByVideoId(videoID string) *[]*Timecode { + args := m.Called(videoID) + + collection := args.Get(0).(*[]*Timecode) + if videoID == "no-items" { + return collection + } + + collection = &[]*Timecode{{}, {}, {}} + + return collection +} + +func (m *MockTimecodeRepository) Create(timecode *Timecode) (*Timecode, error) { + args := m.Called(timecode) + + err := args.Error(1) + + if len(timecode.Description) == 0 { + return nil, err + } + + _ = args.Get(0).(*Timecode) + + return timecode, nil +} + +func (m *MockTimecodeRepository) CreateFromParsedCodes(parsedCodes []timecodeParser.ParsedTimeCode, videoID string) *[]*Timecode { + args := m.Called(parsedCodes, videoID) + + return args.Get(0).(*[]*Timecode) +} + +func Test_handleGetTimecodes(t *testing.T) { + currentUser := &User{} + currentUser.ID = 1 + + t.Run("when timecodes exist", func(t *testing.T) { + timecodes := &[]*Timecode{{}, {}, {}} + + mockTimecodeRepo.On("FindByVideoId", "video-id").Return(timecodes, nil) + + req, _ := http.NewRequest(http.MethodGet, "/timecodes/video-id", nil) + + response := executeRequest(t, timecodesRouter, req, currentUser) + + mockTimecodeRepo.AssertExpectations(t) + assert.Equal(t, http.StatusOK, response.Code) + }) + + t.Run("when timecodes don't exist", func(t *testing.T) { + t.Skip() + timecodes := &[]*Timecode{} + + mockTimecodeRepo.On("FindByVideoId", "no-items").Return(timecodes, nil) + + req, _ := http.NewRequest(http.MethodGet, "/timecodes/video-id", nil) + + response := executeRequest(t, timecodesRouter, req, currentUser) + + mockTimecodeRepo.AssertExpectations(t) + assert.Equal(t, http.StatusOK, response.Code) + }) +} + +func Test_handleCreateTimecode(t *testing.T) { + currentUser := &User{} + currentUser.ID = 1 + + t.Run("when request params are valid", func(t *testing.T) { + timecode := &Timecode{VideoID: "video-id", Seconds: 111, Description: "ABC"} + + mockTimecodeRepo.On("Create", timecode).Return(timecode, nil) + + params := []byte(`{ "videoId": "video-id", "seconds": 111, "description": "ABC" }`) + req, _ := http.NewRequest(http.MethodPost, "/auth/timecodes", bytes.NewBuffer(params)) + + response := executeRequest(t, timecodesRouter, req, currentUser) + + mockTimecodeRepo.AssertExpectations(t) + assert.Equal(t, http.StatusOK, response.Code) + }) +} diff --git a/cmd/user_repository.go b/cmd/user_repository.go new file mode 100644 index 0000000..8bfecfd --- /dev/null +++ b/cmd/user_repository.go @@ -0,0 +1,34 @@ +package main + +import ( + googleAPI "timecodes/cmd/google_api" + + "github.com/jinzhu/gorm" +) + +type User struct { + gorm.Model + Email string + GoogleID string + PictureURL string +} + +type UserRepository interface { + FindOrCreateByGoogleInfo(*googleAPI.UserInfo) *User +} + +type DBUserRepository struct { + UserRepository + + DB *gorm.DB +} + +func (repo *DBUserRepository) FindOrCreateByGoogleInfo(userInfo *googleAPI.UserInfo) *User { + user := &User{} + + repo.DB.Where(User{GoogleID: userInfo.ID}). + Assign(User{Email: userInfo.Email, PictureURL: userInfo.Picture}). + FirstOrCreate(&user) + + return user +} diff --git a/cmd/user_repository_test.go b/cmd/user_repository_test.go new file mode 100644 index 0000000..6988f22 --- /dev/null +++ b/cmd/user_repository_test.go @@ -0,0 +1,59 @@ +package main + +import ( + "testing" + + "github.com/jinzhu/gorm" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + + googleAPI "timecodes/cmd/google_api" +) + +type UserRepositorySuite struct { + suite.Suite + DB *gorm.DB + Repo *DBUserRepository +} + +func (suite *UserRepositorySuite) SetupSuite() { + suite.DB = TestDB + suite.Repo = &DBUserRepository{DB: TestDB} +} + +func (suite *UserRepositorySuite) SetupTest() { + Cleaner.Acquire("users") +} + +func (suite *UserRepositorySuite) TearDownTest() { + Cleaner.Clean("users") +} + +func TestUserRepositorySuite(t *testing.T) { + suite.Run(t, new(UserRepositorySuite)) +} + +func (suite *UserRepositorySuite) TestDBUserRepository_FindOrCreateByGoogleInfo() { + t := suite.T() + + t.Run("when user doesn't exist", func(t *testing.T) { + googleID := "10001" + userInfo := &googleAPI.UserInfo{ID: googleID} + + user := suite.Repo.FindOrCreateByGoogleInfo(userInfo) + + assert.Equal(t, "10001", user.GoogleID) + }) + + t.Run("when user exists", func(t *testing.T) { + googleID := "10002" + + suite.DB.Create(&User{GoogleID: googleID}) + + userInfo := &googleAPI.UserInfo{ID: googleID} + + user := suite.Repo.FindOrCreateByGoogleInfo(userInfo) + + assert.Equal(t, "10002", user.GoogleID) + }) +} diff --git a/cmd/youtube_api/youtube_api.go b/cmd/youtube_api/youtube_api.go index 8fcf0f3..39b7f19 100644 --- a/cmd/youtube_api/youtube_api.go +++ b/cmd/youtube_api/youtube_api.go @@ -9,17 +9,19 @@ import ( "google.golang.org/api/youtube/v3" ) +const GOOGLE_API_KEY = "GOOGLE_API_KEY" + type Service struct { client *youtube.Service } -func New() *Service { - youtubeService, err := youtube.NewService(context.Background(), option.WithAPIKey(os.Getenv("GOOGLE_API_KEY"))) +func New() (*Service, error) { + youtubeService, err := youtube.NewService(context.Background(), option.WithAPIKey(os.Getenv(GOOGLE_API_KEY))) if err != nil { - log.Println(err) + return nil, err } - return &Service{client: youtubeService} + return &Service{client: youtubeService}, nil } func (s *Service) FetchVideoDescription(videoId string) string { @@ -43,7 +45,9 @@ func (s *Service) FetchVideoDescription(videoId string) string { return items[0].Snippet.Description } -func (s *Service) FetchVideoComments(videoId string) ([]*youtube.CommentThread, error) { +func (s *Service) FetchVideoComments(videoId string) []string { + textComments := make([]string, 0) + call := s.client. CommentThreads. List("snippet"). @@ -55,8 +59,12 @@ func (s *Service) FetchVideoComments(videoId string) ([]*youtube.CommentThread, if err != nil { log.Println(err) - return nil, err + return textComments + } + + for _, item := range response.Items { + textComments = append(textComments, item.Snippet.TopLevelComment.Snippet.TextOriginal) } - return response.Items, nil + return textComments } diff --git a/cmd/youtube_api/youtube_api_test.go b/cmd/youtube_api/youtube_api_test.go new file mode 100644 index 0000000..72f1565 --- /dev/null +++ b/cmd/youtube_api/youtube_api_test.go @@ -0,0 +1,146 @@ +package youtubeapi + +import ( + "os" + "testing" + + "github.com/jarcoal/httpmock" + "github.com/stretchr/testify/assert" +) + +func TestMain(m *testing.M) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + registerMockResponders() + + os.Exit(m.Run()) +} + +func registerMockResponders() { + legitVideoResponse := ` + { + "items": [ + { + "id": "legitVideoId", + "snippet": { + "description": "Legit description" + } + } + ] + } + ` + httpmock.RegisterResponder( + "GET", "https://www.googleapis.com/youtube/v3/videos?alt=json&id=legitVideoId&key=CORRECT_KEY&part=snippet&prettyPrint=false", + httpmock.NewStringResponder(200, legitVideoResponse), + ) + + notExistingVideoResponse := `{ "items": [] }` + httpmock.RegisterResponder( + "GET", "https://www.googleapis.com/youtube/v3/videos?alt=json&id=notExistingVideo&key=CORRECT_KEY&part=snippet&prettyPrint=false", + httpmock.NewStringResponder(200, notExistingVideoResponse), + ) + + httpmock.RegisterResponder( + "GET", "https://www.googleapis.com/youtube/v3/videos?alt=json&id=wrongResponse&key=CORRECT_KEY&part=snippet&prettyPrint=false", + httpmock.NewStringResponder(500, "Something went wrong :("), + ) + + legitCommentsResponse := ` + { + "items": [ + { + "snippet": { + "topLevelComment": { + "snippet": { + "textOriginal": "Just a comment." + } + } + } + } + ] + } + ` + httpmock.RegisterResponder( + "GET", "https://www.googleapis.com/youtube/v3/commentThreads?alt=json&key=CORRECT_KEY&maxResults=100&order=relevance&part=snippet&prettyPrint=false&videoId=legitVideoId", + httpmock.NewStringResponder(200, legitCommentsResponse), + ) +} + +func TestNew(t *testing.T) { + t.Run("when youtube.NewService returns an error", func(t *testing.T) { + os.Setenv(GOOGLE_API_KEY, "") + defer os.Unsetenv(GOOGLE_API_KEY) + + service, err := New() + + assert.Nil(t, service) + assert.Contains(t, err.Error(), "google: could not find default credentials") + }) + + t.Run("when returns correct service", func(t *testing.T) { + os.Setenv(GOOGLE_API_KEY, "CORRECT_KEY") + defer os.Unsetenv(GOOGLE_API_KEY) + + service, err := New() + + assert.NotNil(t, service) + assert.Equal(t, "https://www.googleapis.com/youtube/v3/", service.client.BasePath) + assert.Nil(t, err) + }) +} + +func TestService_FetchVideoDescription(t *testing.T) { + os.Setenv(GOOGLE_API_KEY, "CORRECT_KEY") + defer os.Unsetenv(GOOGLE_API_KEY) + + t.Run("when video exists", func(t *testing.T) { + service, _ := New() + + videoID := "legitVideoId" + description := service.FetchVideoDescription(videoID) + + assert.Equal(t, "Legit description", description) + }) + + t.Run("when video doesn't exist", func(t *testing.T) { + service, _ := New() + + videoID := "notExistingVideo" + description := service.FetchVideoDescription(videoID) + + assert.Equal(t, "", description) + }) + + t.Run("when http client returns an error", func(t *testing.T) { + service, _ := New() + + videoID := "wrongResponse" + description := service.FetchVideoDescription(videoID) + + assert.Equal(t, "", description) + }) +} + +func TestService_FetchVideoComments(t *testing.T) { + os.Setenv(GOOGLE_API_KEY, "CORRECT_KEY") + defer os.Unsetenv(GOOGLE_API_KEY) + + t.Run("when video exists", func(t *testing.T) { + service, _ := New() + + videoID := "legitVideoId" + comments := service.FetchVideoComments(videoID) + + assert.Equal(t, "Just a comment.", comments[0]) + }) + + t.Run("when client returns an error", func(t *testing.T) { + service, _ := New() + + videoID := "wrongResponse" + comments := service.FetchVideoComments(videoID) + + assert.Empty(t, comments) + }) +} diff --git a/docker-compose.yml b/docker-compose.yml index e48efd5..cd6afd3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,21 @@ version: "3.5" services: + app_test: + build: + context: . + dockerfile: Dockerfile + target: builder + depends_on: + - db + environment: + - CGO_ENABLED=0 + env_file: + - .env.test + ports: + - "8080:8080" + volumes: + - .:/usr/src/app app_dev: build: context: . @@ -32,7 +47,8 @@ services: environment: - POSTGRES_USER=postgres - POSTGRES_PASSWORD=postgres - - POSTGRES_DB=timecodes_db + env_file: + - .env ports: - 9000:5432 nginx: diff --git a/go.mod b/go.mod index 7668293..3ab13b4 100644 --- a/go.mod +++ b/go.mod @@ -3,9 +3,13 @@ module timecodes go 1.13 require ( + github.com/alexflint/go-filemutex v1.1.0 // indirect github.com/gorilla/mux v1.7.4 + github.com/jarcoal/httpmock v1.0.5 github.com/jinzhu/gorm v1.9.12 + github.com/khaiql/dbcleaner v2.3.0+incompatible github.com/rs/cors v1.7.0 github.com/stretchr/testify v1.5.1 - google.golang.org/api v0.21.0 + google.golang.org/api v0.24.0 + gopkg.in/khaiql/dbcleaner.v2 v2.3.0 ) diff --git a/go.sum b/go.sum index 765424b..d325eff 100644 --- a/go.sum +++ b/go.sum @@ -2,34 +2,88 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMT cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.38.0 h1:ROfEUZz+Gh5pa62DJWXSaonyu3StP6EA6lPEXPI6mCo= cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= +cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= +cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= +cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= +cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= +cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= +cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= +cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= +cloud.google.com/go v0.56.0 h1:WRz29PgAsVEyPSDHyk+0fpEkwEFyfhHn+JbksT6gIL4= +cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= +cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= +cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= +cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= +cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= +cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= +cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= +cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= +cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= +cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= +cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= +cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= +dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/alexflint/go-filemutex v1.1.0 h1:IAWuUuRYL2hETx5b8vCgwnD+xSdlsTQY6s2JjBsqLdg= +github.com/alexflint/go-filemutex v1.1.0/go.mod h1:7P4iRhttt/nUvUOrYIhcpMzv2G6CY9UnI16Z+UJqRyk= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/denisenkom/go-mssqldb v0.0.0-20191124224453-732737034ffd h1:83Wprp6ROGeiHFAP8WJdI2RoxALQYgdllERc3N5N2DM= github.com/denisenkom/go-mssqldb v0.0.0-20191124224453-732737034ffd/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5 h1:Yzb9+7DPaBjB8zlTR87/ElzFsnQfuHnVUVqpZZIcV5Y= github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0= +github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-sql-driver/mysql v1.4.1 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA= github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe h1:lXe2qZdvpiX5WZkZR4hgp4KJVfY3nMkvmwbVkpv1rVY= github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b h1:VKtxabqXZkF25pY9ekfRL6a582T4P37/31XEstQ5p58= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e h1:1r7pUrabqp18hOBcwBwiTsbnFeTZHV9eER/QT5JVZxY= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= +github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.5 h1:F768QJ1E9tib+q5Sc8MkdJi1RxLTbRcTf8LJV56aRls= +github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0 h1:crn/baboCvb5fXaQ0IJ1SGTsTVrWpDsCWC8EGETZijY= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0 h1:xsAVV57WRhGj6kEIi8ReJzQlHHqcBYCElAvkovg3B/4= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5 h1:sjZBwGj9Jlw33ImPtvFviGYvseOtDM7hkSKB7+Tv3SM= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= @@ -38,6 +92,9 @@ github.com/gorilla/mux v1.7.4/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB7 github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1 h1:0hERBMJE1eitiLkihrMvRVBYAkpHzc/J3QdDN+dAcgU= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/jarcoal/httpmock v1.0.5 h1:cHtVEcTxRSX4J0je7mWPfc9BpDpqzXSJ5HbymZmyHck= +github.com/jarcoal/httpmock v1.0.5/go.mod h1:ATjnClrvW/3tijVmpL/va5Z3aAyGvqU3gCT8nX0Txik= github.com/jinzhu/gorm v1.9.12 h1:Drgk1clyWT9t9ERbzHza6Mj/8FY/CqMyVzOiHviMo6Q= github.com/jinzhu/gorm v1.9.12/go.mod h1:vhTjlKSJUTWNtcbQtrMBFCxy7eXTzeCAzfL5fBZT/Qs= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= @@ -45,6 +102,15 @@ github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkr github.com/jinzhu/now v1.0.1 h1:HjfetcXq097iXP0uoPCdnM4Efp5/9MsM0/M+XOTeR3M= github.com/jinzhu/now v1.0.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= +github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= +github.com/khaiql/dbcleaner v2.3.0+incompatible h1:VU/ZnMcs0Dx6s4XELYfZMMyib2hyrnJ0Xk1/2aZQIqg= +github.com/khaiql/dbcleaner v2.3.0+incompatible/go.mod h1:NUURNSEp3cHXCm37Ljb/IWAdp2/qYv/HAW+1BdnEbps= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/lib/pq v1.1.1 h1:sJZmqHoEaY7f+NPP8pgLB/WxulyR3fewgCM2qaSlBb4= github.com/lib/pq v1.1.1/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/mattn/go-sqlite3 v2.0.1+incompatible h1:xQ15muvnzGBHpIpdrNi1DA5x0+TcBZzsIDwmw9uTHzw= @@ -52,76 +118,223 @@ github.com/mattn/go-sqlite3 v2.0.1+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW 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/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rs/cors v1.7.0 h1:+88SsELBHx5r+hZ8TCkggzSstaWNbDvThkVK8H6f9ik= github.com/rs/cors v1.7.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU= +github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4= 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.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= go.opencensus.io v0.21.0 h1:mU6zScU4U1YAFPHEHYk+3JC4SY7JxgkqS10ZOSyksNg= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= +go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= +go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.3 h1:8sGtKOrtQqkN1bp2AtX+misvLIlOmsEsNd+9NIcPEm8= +go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191205180655-e7c4368fe9dd h1:GGJVjV8waZKRHrgwvtH66z9ZGVurTD1MT0n1Bb+q4aM= golang.org/x/crypto v0.0.0-20191205180655-e7c4368fe9dd/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= +golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= +golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= +golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= +golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= +golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20200302205851-738671d3881b h1:Wh+f8QHJXR411sJR8/vRBTZ7YapZaRvUcLFFJhusH0k= +golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= +golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= +golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.2.0 h1:KU7oHjnv3XNWfa5COkzUifxZmxp1TyI7ImMXqFxLwvQ= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c h1:uOCk1iQW6Vc18bnC13MfzScl+wdKBmM9Y9kU7Z83/lw= golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e h1:3G+cUijn7XD+S4eJFddp53Pv7+slrESplyjG25HgL+k= +golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 h1:SVwTIAaPC2U/AvvLNZ2a7OVsmBpC8L5BlwK1whH3hm0= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d h1:TzXSXBo42m9gQenoE3b9BGiEpg5IG2JkU5FkPIawgtw= +golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b h1:ag/x1USPSsqHud38I9BAC88qdNLDHHtQ4mlgQIZPPNA= golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200331124033-c3d80250170d h1:nc5K6ox/4lTFbMVSL9WRR81ixkcwXThoiF6yf+R9scA= +golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4 h1:kDtqNkeBrZb8B+atrj50B5XLHpzXXqcCdZPP/ApQ5NY= +golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= -google.golang.org/api v0.21.0 h1:zS+Q/CJJnVlXpXQVIz+lH0ZT2lBuT2ac7XD8Y/3w6hY= -google.golang.org/api v0.21.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= +google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.24.0 h1:cG03eaksBzhfSIk7JRGctfp3lanklcOM/mTGvow7BbQ= +google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0 h1:/wp5JvzpHIxhs/dumFmF7BXTf3Z+dd4uXta4kVyO508= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.5.0 h1:KxkO13IPW4Lslp2bz+KHP2E3gtFlrIGNThxkZQ3g+4c= google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= +google.golang.org/appengine v1.6.5 h1:tycE03LOZYQNhDpS27tcQdAzLCVMaj7QT2SXxebnpCM= +google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55 h1:gSJIx1SDwno+2ElGhA4+qG2zF97qiUzTM+rQ0klBOcE= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= +google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= +google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940 h1:MRHtG0U6SnaUb+s+LhNE1qt1FQ1wlhqr5E4usBKC0uA= +google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= +google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.27.0 h1:rRYRFMVgRv6E0D70Skyfsr28tDXIuuPZyWGMPdMcnXg= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.28.0 h1:bO/TA4OxCOummhSf10siHuG7vJOiwh7SpRpFZDkOgl4= +google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= +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/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/khaiql/dbcleaner.v2 v2.3.0 h1:9tRNEo5tn7MhpHySz5Rstjt7iuTl2oIMRLYlLUf1BOA= +gopkg.in/khaiql/dbcleaner.v2 v2.3.0/go.mod h1:kzKBqwVHZ7o00FtbCfDKRDNfSkPGAgDJMQ8bd6ruy30= gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= +honnef.co/go/tools v0.0.1-2020.1.3 h1:sXmLre5bzIR6ypkjXCDI3jHPssRhc8KD/Ome589sc3U= +honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= +rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= +rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=