diff --git a/.gitignore b/.gitignore index b81fdc5..83c5631 100644 --- a/.gitignore +++ b/.gitignore @@ -194,3 +194,4 @@ fabric.properties /events.db /events.db.wal /duckdb-driver +/GeoLite2-City.mmdb diff --git a/go.mod b/go.mod index 791afa8..989c47f 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/golang-jwt/jwt/v5 v5.2.0 github.com/google/uuid v1.5.0 github.com/joho/godotenv v1.5.1 + github.com/oschwald/geoip2-golang v1.9.0 github.com/samber/lo v1.39.0 github.com/stretchr/testify v1.8.4 github.com/swaggo/swag v1.16.2 @@ -47,6 +48,7 @@ require ( github.com/mattn/go-runewidth v0.0.15 // indirect github.com/mattn/go-sqlite3 v1.14.22 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/oschwald/maxminddb-golang v1.11.0 // indirect github.com/paulmach/orb v0.11.0 // indirect github.com/pierrec/lz4/v4 v4.1.21 // indirect github.com/pkg/errors v0.9.1 // indirect diff --git a/go.sum b/go.sum index 6e92891..7567680 100644 --- a/go.sum +++ b/go.sum @@ -749,8 +749,6 @@ github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghf github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/championswimmer/duckdb-driver v0.1.0/go.mod h1:wkHe/zl2ERZDFN/G+ZGVe9Ip+QWPwbsI4dXrRixBRc4= -github.com/championswimmer/duckdb-driver v0.2.0/go.mod h1:zoGrzT9RpdOfCEOCDx4fZ1s87Rpx3D1QUIEzwxHWGLo= github.com/championswimmer/duckdb-driver v0.2.1 h1:1wjIhRLE3NQjwMLntAPRXw5IdkxdMPkDxxr7i3vW4vE= github.com/championswimmer/duckdb-driver v0.2.1/go.mod h1:zoGrzT9RpdOfCEOCDx4fZ1s87Rpx3D1QUIEzwxHWGLo= github.com/checkpoint-restore/go-criu/v4 v4.1.0/go.mod h1:xUQBLp4RLc5zJtWY++yjOoMoB5lihDt7fai+75m+rGw= @@ -1588,6 +1586,10 @@ github.com/opencontainers/selinux v1.10.0/go.mod h1:2i0OySw99QjzBBQByd1Gr9gSjvuh github.com/opencontainers/selinux v1.10.1/go.mod h1:2i0OySw99QjzBBQByd1Gr9gSjvuho1lHsJxIJ3gGbJI= github.com/opencontainers/selinux v1.11.0/go.mod h1:E5dMC3VPuVvVHDYmi78qvhJp8+M586T4DlDRYpFkyec= github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= +github.com/oschwald/geoip2-golang v1.9.0 h1:uvD3O6fXAXs+usU+UGExshpdP13GAqp4GBrzN7IgKZc= +github.com/oschwald/geoip2-golang v1.9.0/go.mod h1:BHK6TvDyATVQhKNbQBdrj9eAvuwOMi2zSFXizL3K81Y= +github.com/oschwald/maxminddb-golang v1.11.0 h1:aSXMqYR/EPNjGE8epgqwDay+P30hCBZIveY0WZbAWh0= +github.com/oschwald/maxminddb-golang v1.11.0/go.mod h1:YmVI+H0zh3ySFR3w+oz8PCfglAFj3PuCmui13+P9zDg= github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pascaldekloe/name v1.0.0/go.mod h1:Z//MfYJnH4jVpQ9wkclwu2I2MkHmXTlT9wR5UZScttM= github.com/pascaldekloe/name v1.0.1/go.mod h1:Z//MfYJnH4jVpQ9wkclwu2I2MkHmXTlT9wR5UZScttM= @@ -2815,12 +2817,8 @@ 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/clickhouse v0.6.0 h1:nyhaeQ92qFEqf47B5N/vwPnnqV2DAuSHPC0QmlZrVZI= gorm.io/driver/clickhouse v0.6.0/go.mod h1:UtkbKNA4ibWTCzVkuFY80hBsb82nTH335JUVUKvT9YY= -gorm.io/driver/postgres v1.5.4 h1:Iyrp9Meh3GmbSuyIAGyjkN+n9K+GHX9b9MqsTL4EJCo= -gorm.io/driver/postgres v1.5.4/go.mod h1:Bgo89+h0CRcdA33Y6frlaHHVuTdOf87pmyzwW9C/BH0= gorm.io/driver/postgres v1.5.6 h1:ydr9xEd5YAM0vxVDY0X139dyzNz10spDiDlC7+ibLeU= gorm.io/driver/postgres v1.5.6/go.mod h1:3e019WlBaYI5o5LIdNV+LyxCMNtLOQETBXL2h4chKpA= -gorm.io/driver/sqlite v1.5.4 h1:IqXwXi8M/ZlPzH/947tn5uik3aYQslP9BVveoax0nV0= -gorm.io/driver/sqlite v1.5.4/go.mod h1:qxAuCol+2r6PannQDpOP1FP6ag3mKi4esLnB/jHed+4= gorm.io/driver/sqlite v1.5.5 h1:7MDMtUZhV065SilG62E0MquljeArQZNfJnjd9i9gx3E= gorm.io/driver/sqlite v1.5.5/go.mod h1:6NgQ7sQWAIFsPrJJl1lSNSu2TABh0ZZ/zm5fosATavE= gorm.io/gorm v1.24.6/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k= diff --git a/src/config/consts.go b/src/config/consts.go new file mode 100644 index 0000000..ae4d8d9 --- /dev/null +++ b/src/config/consts.go @@ -0,0 +1,5 @@ +package config + +const ( + LOCALS_USER = "user" +) diff --git a/src/controllers/events.go b/src/controllers/events.go index 20ea508..5209ca9 100644 --- a/src/controllers/events.go +++ b/src/controllers/events.go @@ -1,9 +1,12 @@ package controllers import ( + "fmt" "github.com/google/uuid" + "github.com/oschwald/geoip2-golang" "github.com/samber/lo" "gorm.io/gorm" + "net" "onepixel_backend/src/db" "onepixel_backend/src/db/models" "onepixel_backend/src/utils/applogger" @@ -12,12 +15,13 @@ import ( type EventsController struct { // event logging eventDb (not the main app eventDb) eventDb *gorm.DB + geoipDB *geoip2.Reader } func CreateEventsController() *EventsController { - eventsDB := lo.Must(db.GetEventsDB()) return &EventsController{ - eventDb: eventsDB, + eventDb: db.GetEventsDB(), + geoipDB: db.GetGeoIPDB(), } } @@ -34,6 +38,7 @@ type EventRedirectDTO struct { func (c *EventsController) LogRedirectAsync(redirData *EventRedirectDTO) { lo.Async(func() uuid.UUID { + event := &models.EventRedirect{ ID: uuid.New(), ShortURL: redirData.ShortURL, @@ -44,6 +49,24 @@ func (c *EventsController) LogRedirectAsync(redirData *EventRedirectDTO) { IPAddress: redirData.IPAddress, Referer: redirData.Referer, } + ip := net.ParseIP(redirData.IPAddress) + + if ip != nil { + city, err := c.geoipDB.City(ip) + if err == nil { + if city.Country.Names["en"] != "" { + event.LocationCountry = fmt.Sprintf("%s (%s)", city.Country.Names["en"], city.Country.IsoCode) + } + + if city.Subdivisions[0].Names["en"] != "" { + event.LocationRegion = fmt.Sprintf("%s (%s)", city.Subdivisions[0].Names["en"], city.Subdivisions[0].IsoCode) + } + + if city.City.Names["en"] != "" { + event.LocationCity = city.City.Names["en"] + } + } + } lo.Try(func() error { tx := c.eventDb.Create(event) return tx.Error @@ -53,14 +76,16 @@ func (c *EventsController) LogRedirectAsync(redirData *EventRedirectDTO) { } -func (c *EventsController) GetRedirectsCountForUserId(userId string) []models.EventRedirectCountView { +func (c *EventsController) GetRedirectsCountForUserId(userId uint64) ([]models.EventRedirectCountView, error) { rows, err := c.eventDb.Model(&models.EventRedirect{}). Select("count(id) as redirects, short_url"). + Where("creator_id = ?", userId). Group("short_url"). Rows() if err != nil { - applogger.Panic("GetRedirectsCountForUserId: ", err) + applogger.Error("GetRedirectsCountForUserId: ", err) + return nil, err } data := make([]models.EventRedirectCountView, 0) for rows.Next() { @@ -68,5 +93,5 @@ func (c *EventsController) GetRedirectsCountForUserId(userId string) []models.Ev lo.Must0(c.eventDb.Model(&models.EventRedirect{}).ScanRows(rows, &d)) data = append(data, d) } - return data + return data, nil } diff --git a/src/controllers/urls.go b/src/controllers/urls.go index 311af2b..f3c1853 100644 --- a/src/controllers/urls.go +++ b/src/controllers/urls.go @@ -79,7 +79,7 @@ func (c *UrlsController) initDefaultUrlGroup() { } func CreateUrlsController() *UrlsController { - appDb := lo.Must(db.GetAppDB()) + appDb := db.GetAppDB() ctrl := &UrlsController{ db: appDb, } diff --git a/src/controllers/users.go b/src/controllers/users.go index 6afef95..04f3de5 100644 --- a/src/controllers/users.go +++ b/src/controllers/users.go @@ -3,7 +3,6 @@ package controllers import ( "errors" "github.com/google/uuid" - "github.com/samber/lo" "gorm.io/gorm" "onepixel_backend/src/db" "onepixel_backend/src/db/models" @@ -57,7 +56,7 @@ func (c *UsersController) initDefaultUser() { var initDefaultUserOnce sync.Once func CreateUsersController() *UsersController { - appDb := lo.Must(db.GetAppDB()) + appDb := db.GetAppDB() ctrl := &UsersController{ db: appDb, } diff --git a/src/db/init.go b/src/db/init.go index 798ad04..f6fa365 100644 --- a/src/db/init.go +++ b/src/db/init.go @@ -1,10 +1,12 @@ package db import ( - "errors" + "github.com/oschwald/geoip2-golang" "onepixel_backend/src/config" "onepixel_backend/src/db/models" + "onepixel_backend/src/utils" "onepixel_backend/src/utils/applogger" + "os" "sync" "github.com/samber/lo" @@ -12,10 +14,12 @@ import ( "gorm.io/gorm/logger" ) -var appDb *gorm.DB // singleton -var eventsDb *gorm.DB // singleton +var appDb *gorm.DB // singleton +var eventsDb *gorm.DB // singleton +var reader *geoip2.Reader // singleton var createAppDbOnce sync.Once var createEventsDbOnce sync.Once +var createGeoIPDbOnce sync.Once func getGormConfig() (dbConfig *gorm.Config) { dbConfig = &gorm.Config{ @@ -44,7 +48,7 @@ func init() { InjectDBProvider("clickhouse", ProvideClickhouseDB) } -func GetAppDB() (*gorm.DB, error) { +func GetAppDB() *gorm.DB { createAppDbOnce.Do(func() { applogger.Warn("App: Initialising database") @@ -64,10 +68,10 @@ func GetAppDB() (*gorm.DB, error) { lo.Must0(appDb.AutoMigrate(&models.Url{})) }) - return appDb, nil + return appDb } -func GetEventsDB() (*gorm.DB, error) { +func GetEventsDB() *gorm.DB { createEventsDbOnce.Do(func() { applogger.Warn("Events: Initialising database") @@ -83,23 +87,37 @@ func GetEventsDB() (*gorm.DB, error) { } // automigrate table if we cannot get column types - lo.TryCatchWithErrorValue(func() error { - if eventsDb.Migrator().HasTable((&models.EventRedirect{}).TableName()) { - applogger.Info("Events: table exists") - return nil - } else { - return errors.New("table not found") - } - //_, err := eventsDb.Migrator().ColumnTypes((&models.EventRedirect{}).TableName()) - //return err - }, func(e any) { - applogger.Error("Error reading column types of eventsdb: " + e.(error).Error()) - lo.Must0(eventsDb.AutoMigrate(&models.EventRedirect{})) - applogger.Info("Events: table automigrated") - + err, success := lo.TryWithErrorValue(func() error { + return eventsDb.AutoMigrate(&models.EventRedirect{}) }) + if !success { + applogger.Error("Events: AutoMigrate failed", err) + } + + }) + + return eventsDb +} + +func GetGeoIPDB() *geoip2.Reader { + + // download file : https://git.io/GeoLite2-City.mmdb + createGeoIPDbOnce.Do(func() { + applogger.Warn("GeoIP: Initialising database") + fresh := utils.IsFileFresh(30, "GeoLite2-City.mmdb") + if !fresh { + applogger.Error("GeoIP: GeoLite2-City.mmdb is not fresh; downloading again") + lo.Try(func() error { + return os.Remove("GeoLite2-City.mmdb") + }) + lo.Must0(utils.DownloadFile("https://git.io/GeoLite2-City.mmdb", "GeoLite2-City.mmdb")) + applogger.Info("GeoIP: GeoLite2-City.mmdb downloaded") + } + + reader = lo.Must(geoip2.Open("GeoLite2-City.mmdb")) }) - return eventsDb, nil + return reader + } diff --git a/src/db/models/event_redirect.go b/src/db/models/event_redirect.go index ba772b9..ce6776a 100644 --- a/src/db/models/event_redirect.go +++ b/src/db/models/event_redirect.go @@ -19,7 +19,10 @@ type EventRedirect struct { // user agent UserAgent string `gorm:"type:string"` // ip address - IPAddress string `gorm:"type:string"` + IPAddress string `gorm:"type:string"` + LocationCity string `gorm:"type:string"` + LocationRegion string `gorm:"type:string"` + LocationCountry string `gorm:"type:string"` // referer Referer string `gorm:"type:string"` } diff --git a/src/dtos/http_responses.go b/src/dtos/http_responses.go index 26d9593..117347f 100644 --- a/src/dtos/http_responses.go +++ b/src/dtos/http_responses.go @@ -23,6 +23,9 @@ type ErrorResponse struct { Message string `json:"message" example:"Something went wrong"` } +type RedirectCountStatsResponse struct { +} + func CreateUserResponseFromUser(user *models.User, token *string) UserResponse { return UserResponse{ ID: user.ID, diff --git a/src/main.go b/src/main.go index 39df0a5..e5a2cfa 100644 --- a/src/main.go +++ b/src/main.go @@ -19,8 +19,9 @@ import ( func main() { // Initialize the database - appDb := lo.Must(db.GetAppDB()) - _ = lo.Must(db.GetEventsDB()) + appDb := db.GetAppDB() + eventDb := db.GetEventsDB() + geoipDb := db.GetGeoIPDB() // Create the app adminApp := server.CreateAdminApp() @@ -58,6 +59,9 @@ func main() { defer cancel() lo.Must0(lo.Must(appDb.DB()).Close()) + lo.Must0(lo.Must(eventDb.DB()).Close()) + lo.Must0(geoipDb.Close()) + lo.Must0(adminApp.ShutdownWithContext(ctx)) lo.Must0(mainApp.ShutdownWithContext(ctx)) lo.Must0(app.ShutdownWithContext(ctx)) diff --git a/src/routes/api/stats.go b/src/routes/api/stats.go index 9438774..02c5559 100644 --- a/src/routes/api/stats.go +++ b/src/routes/api/stats.go @@ -1,9 +1,14 @@ package api import ( - "github.com/gofiber/fiber/v2" + "onepixel_backend/src/config" "onepixel_backend/src/controllers" + "onepixel_backend/src/db/models" + "onepixel_backend/src/dtos" + "onepixel_backend/src/security" "onepixel_backend/src/utils/applogger" + + "github.com/gofiber/fiber/v2" ) var eventsController *controllers.EventsController @@ -14,12 +19,27 @@ func StatsRoute() func(router fiber.Router) { eventsController = controllers.CreateEventsController() return func(router fiber.Router) { - router.Get("/", getStats) + router.Get("/", security.OptionalJwtAuthMiddleware, getAllStats) + router.Get("/:shortcode" /*security.MandatoryJwtAuthMiddleware,*/, getStatsForShortCode) + // TODO: add stats for grouped shortcodes + } +} + +func getAllStats(ctx *fiber.Ctx) error { + // TODO: handle null case + user := ctx.Locals(config.LOCALS_USER).(*models.User) + stats, err := eventsController.GetRedirectsCountForUserId(user.ID) + + if err != nil { + applogger.Error(err) + return ctx.Status(fiber.StatusInternalServerError).JSON(dtos.CreateErrorResponse(fiber.StatusInternalServerError, "something went wrong")) } + + return ctx.Status(fiber.StatusOK).JSON(stats) } -func getStats(ctx *fiber.Ctx) error { - stats := eventsController.GetRedirectsCountForUserId("") - applogger.Info("Stats: ", len(stats)) +func getStatsForShortCode(ctx *fiber.Ctx) error { + // stats := eventsController.GetRedirectsCountForUserId("") + // applogger.Info("Stats: ", len(stats)) return ctx.SendString("GetStats") } diff --git a/src/routes/api/urls.go b/src/routes/api/urls.go index 1482974..11cae8b 100644 --- a/src/routes/api/urls.go +++ b/src/routes/api/urls.go @@ -2,13 +2,15 @@ package api import ( "errors" - "github.com/gofiber/fiber/v2" + "onepixel_backend/src/config" "onepixel_backend/src/controllers" "onepixel_backend/src/db/models" "onepixel_backend/src/dtos" "onepixel_backend/src/security" "onepixel_backend/src/server/parsers" "onepixel_backend/src/server/validators" + + "github.com/gofiber/fiber/v2" ) var urlsController *controllers.UrlsController @@ -54,7 +56,7 @@ func getAllUrls(ctx *fiber.Ctx) error { // @Router /urls [post] // @Security BearerToken func createRandomUrl(ctx *fiber.Ctx) error { - user := ctx.Locals("user").(*models.User) + user := ctx.Locals(config.LOCALS_USER).(*models.User) cur, parseErr := parsers.ParseBody[dtos.CreateUrlRequest](ctx) if parseErr != nil { @@ -92,7 +94,7 @@ func createRandomUrl(ctx *fiber.Ctx) error { // @Router /urls/{shortcode} [put] // @Security BearerToken func createSpecificUrl(ctx *fiber.Ctx) error { - user := ctx.Locals("user").(*models.User) + user := ctx.Locals(config.LOCALS_USER).(*models.User) cur, parseErr := parsers.ParseBody[dtos.CreateUrlRequest](ctx) if parseErr != nil { diff --git a/src/utils/download.go b/src/utils/download.go new file mode 100644 index 0000000..85e40df --- /dev/null +++ b/src/utils/download.go @@ -0,0 +1,45 @@ +package utils + +import ( + "fmt" + "io" + "net/http" + "onepixel_backend/src/utils/applogger" + "os" + "time" +) + +func IsFileFresh(maxdays int, filepath string) bool { + fileInfo, err := os.Stat(filepath) + if err != nil { + applogger.Error("CheckFileAge: ", err) + return false + } + fileAge := time.Since(fileInfo.ModTime()) + return fileAge.Hours() < float64(maxdays*24) +} + +func DownloadFile(url string, filepath string) error { + // Send a GET request to the file URL + resp, err := http.Get(url) + if err != nil { + return err + } + defer resp.Body.Close() + + // Check the HTTP response status code + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("bad status: %s", resp.Status) + } + + // Create a new file + out, err := os.Create(filepath) + if err != nil { + return err + } + defer out.Close() + + // Copy the response body to the file + _, err = io.Copy(out, resp.Body) + return err +} diff --git a/tests/db/geoip_test.go b/tests/db/geoip_test.go new file mode 100644 index 0000000..d7d7225 --- /dev/null +++ b/tests/db/geoip_test.go @@ -0,0 +1,35 @@ +package db + +import ( + "github.com/samber/lo" + "github.com/stretchr/testify/assert" + "net" + "onepixel_backend/src/db" + "testing" +) + +func Test_GeoIPResolutionIPV4(t *testing.T) { + geoipDB := db.GetGeoIPDB() + + // Test IPV4 + city := lo.Must(geoipDB.City(net.ParseIP("42.108.28.82"))) + + assert.Equal(t, "IN", city.Country.IsoCode) + assert.Equal(t, "DL", city.Subdivisions[0].IsoCode) + assert.Equal(t, "India", city.Country.Names["en"]) + assert.Equal(t, "Delhi", city.City.Names["en"]) + +} + +func Test_GeoIPResolutionIPV6(t *testing.T) { + geoipDB := db.GetGeoIPDB() + + // Test IPV4 + city := lo.Must(geoipDB.City(net.ParseIP("2402:3a80:43b8:e640:e8d2:94e2:1c54:8847"))) + + assert.Equal(t, "IN", city.Country.IsoCode) + assert.Equal(t, "DL", city.Subdivisions[0].IsoCode) + assert.Equal(t, "India", city.Country.Names["en"]) + assert.Equal(t, "New Delhi", city.City.Names["en"]) + +} diff --git a/tests/routes/api/urls_test.go b/tests/routes/api/urls_test.go index 8031f22..f9857e7 100644 --- a/tests/routes/api/urls_test.go +++ b/tests/routes/api/urls_test.go @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/assert" "io" "net/http/httptest" + "onepixel_backend/src/db/models" "onepixel_backend/src/dtos" "onepixel_backend/src/utils/applogger" "onepixel_backend/tests" @@ -43,7 +44,9 @@ func TestUrlsRoute_CreateRandomUrl(t *testing.T) { // ------ CHECK REDIRECT ------ chans := lo.Times(3, func(i int) <-chan string { return lo.Async(func() string { - req = httptest.NewRequest("GET", "/"+urlResponseBody.ShortURL, nil) + req := httptest.NewRequest("GET", "/"+urlResponseBody.ShortURL, nil) + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3") + req.Header.Set("X-Forwarded-For", "42.108.28.82") resp = lo.Must(tests.MainApp.Test(req)) assert.Equal(t, 301, resp.StatusCode) @@ -56,6 +59,26 @@ func TestUrlsRoute_CreateRandomUrl(t *testing.T) { // give time for analytics to flush time.Sleep(1 * time.Second) + // ------ CHECK STATS ------ + + req = httptest.NewRequest("GET", "/api/v1/stats", nil) + req.Header.Set("Content-Type", "application/json; charset=UTF-8") + req.Header.Set("Authorization", *responseBody.Token) + resp = lo.Must(tests.App.Test(req)) + + assert.Equal(t, 200, resp.StatusCode) + + var statsResponseBody []models.EventRedirectCountView + + body, err = io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("Error reading response body: %v", err) + } + if err := json.Unmarshal(body, &statsResponseBody); err != nil { + t.Fatalf("Error unmarshalling response body: %v", err) + } + assert.GreaterOrEqual(t, len(statsResponseBody), 1) + } func TestUrlsRoute_CreateSpecificUrl(t *testing.T) {