├── .env.EXAMPLE ├── internal ├── endpoints │ ├── handler.go │ ├── campaigns_delete.go │ ├── campaigns_start.go │ ├── campaigns_get_by_id.go │ ├── campaigns_post.go │ ├── campaigns_start_test.go │ ├── handler_error.go │ ├── campaigns_delete_test.go │ ├── campaigns_get_by_id_test.go │ ├── setup_test.go │ ├── campaigns_post_test.go │ ├── auth.go │ └── handler_error_test.go ├── contract │ ├── NewCampaign.go │ └── NewCampaign copy.go ├── domain │ └── campaign │ │ ├── repository.go │ │ ├── campaign.go │ │ ├── service.go │ │ ├── campaign_test.go │ │ └── service_test.go ├── internal-errors │ ├── errors.go │ └── validator.go ├── infrastructure │ ├── database │ │ ├── new_db.go │ │ └── campaign_repository.go │ └── mail │ │ └── send_mail.go └── test │ └── internal-mock │ ├── campaign_service_mock.go │ └── campaign_repository_mock.go ├── .gitignore ├── README.md ├── .vscode └── launch.json ├── .air.toml ├── cmd ├── worker │ └── main.go └── api │ └── main.go ├── docs └── rest.http ├── go.mod └── go.sum /.env.EXAMPLE: -------------------------------------------------------------------------------- 1 | DATABASE="" 2 | KEYCLOAK="" -------------------------------------------------------------------------------- /internal/endpoints/handler.go: -------------------------------------------------------------------------------- 1 | package endpoints 2 | 3 | import "emailn/internal/domain/campaign" 4 | 5 | type Handler struct { 6 | CampaignService campaign.Service 7 | } 8 | -------------------------------------------------------------------------------- /internal/contract/NewCampaign.go: -------------------------------------------------------------------------------- 1 | package contract 2 | 3 | type NewCampaign struct { 4 | Name string 5 | Content string 6 | Emails []string 7 | CreatedBy string 8 | } 9 | -------------------------------------------------------------------------------- /internal/contract/NewCampaign copy.go: -------------------------------------------------------------------------------- 1 | package contract 2 | 3 | type CampaignResponse struct { 4 | ID string 5 | Name string 6 | Content string 7 | Status string 8 | AmountOfEmailsToSend int 9 | CreatedBy string 10 | } 11 | -------------------------------------------------------------------------------- /internal/domain/campaign/repository.go: -------------------------------------------------------------------------------- 1 | package campaign 2 | 3 | type Repository interface { 4 | Create(campaign *Campaign) error 5 | Update(campaign *Campaign) error 6 | Get() ([]Campaign, error) 7 | GetBy(id string) (*Campaign, error) 8 | Delete(campaign *Campaign) error 9 | GetCampaignsToBeSent() ([]Campaign, error) 10 | } 11 | -------------------------------------------------------------------------------- /internal/endpoints/campaigns_delete.go: -------------------------------------------------------------------------------- 1 | package endpoints 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/go-chi/chi/v5" 7 | ) 8 | 9 | func (h *Handler) CampaignDelete(w http.ResponseWriter, r *http.Request) (interface{}, int, error) { 10 | id := chi.URLParam(r, "id") 11 | err := h.CampaignService.Delete(id) 12 | return nil, 200, err 13 | } 14 | -------------------------------------------------------------------------------- /internal/endpoints/campaigns_start.go: -------------------------------------------------------------------------------- 1 | package endpoints 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/go-chi/chi/v5" 7 | ) 8 | 9 | func (h *Handler) CampaignStart(w http.ResponseWriter, r *http.Request) (interface{}, int, error) { 10 | id := chi.URLParam(r, "id") 11 | err := h.CampaignService.Start(id) 12 | return nil, 200, err 13 | } 14 | -------------------------------------------------------------------------------- /internal/internal-errors/errors.go: -------------------------------------------------------------------------------- 1 | package internalerrors 2 | 3 | import ( 4 | "errors" 5 | 6 | "gorm.io/gorm" 7 | ) 8 | 9 | var ErrInternal error = errors.New("Internal Server Error") 10 | 11 | func ProcessErrorToReturn(err error) error { 12 | if !errors.Is(err, gorm.ErrRecordNotFound) { 13 | return ErrInternal 14 | } 15 | return err 16 | } 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | /tmp 17 | .env 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # EmailN 2 | ## Domain 3 | 4 | Um serviço para envio de email em lote. O objetivo é facilitar, para o sistema cliente, não perder tempo enviando email a email. 5 | 6 | #### Features 7 | 8 | - Endpoint para envio dos e-mails em lote com os contatos 9 | - Endpoint para informar o status do lote enviado. 10 | - Os e-mails enviados poderão ser personalizados por contato, com isso, informações do contato poderão estar no texto do e-mail. 11 | -------------------------------------------------------------------------------- /internal/endpoints/campaigns_get_by_id.go: -------------------------------------------------------------------------------- 1 | package endpoints 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/go-chi/chi/v5" 7 | ) 8 | 9 | // GET /campaigns/{id} 10 | func (h *Handler) CampaignGetById(w http.ResponseWriter, r *http.Request) (interface{}, int, error) { 11 | id := chi.URLParam(r, "id") 12 | campaign, err := h.CampaignService.GetBy(id) 13 | if err == nil && campaign == nil { 14 | return nil, http.StatusNotFound, err 15 | } 16 | return campaign, 200, err 17 | } 18 | -------------------------------------------------------------------------------- /internal/infrastructure/database/new_db.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "emailn/internal/domain/campaign" 5 | "os" 6 | 7 | "gorm.io/driver/postgres" 8 | "gorm.io/gorm" 9 | ) 10 | 11 | func NewDb() *gorm.DB { 12 | dsn := os.Getenv("DATABASE") 13 | db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{}) 14 | 15 | if err != nil { 16 | panic("fail to connect to database") 17 | } 18 | 19 | db.AutoMigrate(&campaign.Campaign{}, &campaign.Contact{}) 20 | 21 | return db 22 | } 23 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Launch Package", 9 | "type": "go", 10 | "request": "launch", 11 | "mode": "auto", 12 | "program": "cmd/api/main.go", 13 | } 14 | ] 15 | } -------------------------------------------------------------------------------- /internal/endpoints/campaigns_post.go: -------------------------------------------------------------------------------- 1 | package endpoints 2 | 3 | import ( 4 | "emailn/internal/contract" 5 | "net/http" 6 | 7 | "github.com/go-chi/render" 8 | ) 9 | 10 | func (h *Handler) CampaignPost(w http.ResponseWriter, r *http.Request) (interface{}, int, error) { 11 | var request contract.NewCampaign 12 | render.DecodeJSON(r.Body, &request) 13 | email := r.Context().Value("email").(string) 14 | request.CreatedBy = email 15 | id, err := h.CampaignService.Create(request) 16 | return map[string]string{"id": id}, 201, err 17 | } 18 | -------------------------------------------------------------------------------- /internal/infrastructure/mail/send_mail.go: -------------------------------------------------------------------------------- 1 | package mail 2 | 3 | import ( 4 | "emailn/internal/domain/campaign" 5 | "fmt" 6 | "os" 7 | 8 | "gopkg.in/gomail.v2" 9 | ) 10 | 11 | func SendMail(campaign *campaign.Campaign) error { 12 | fmt.Println("Sending mail...") 13 | 14 | d := gomail.NewDialer(os.Getenv("EMAIL_SMTP"), 587, os.Getenv("EMAIL_USER"), os.Getenv("EMAIL_PASSWORD")) 15 | 16 | var emails []string 17 | for _, contact := range campaign.Contacts { 18 | emails = append(emails, contact.Email) 19 | } 20 | 21 | m := gomail.NewMessage() 22 | m.SetHeader("From", os.Getenv("EMAIL_USER")) 23 | m.SetHeader("To", emails...) 24 | m.SetHeader("Subject", campaign.Name) 25 | m.SetBody("text/html", campaign.Content) 26 | 27 | return d.DialAndSend(m) 28 | } 29 | -------------------------------------------------------------------------------- /internal/internal-errors/validator.go: -------------------------------------------------------------------------------- 1 | package internalerrors 2 | 3 | import ( 4 | "errors" 5 | "strings" 6 | 7 | "github.com/go-playground/validator/v10" 8 | ) 9 | 10 | func ValidateStruct(obj interface{}) error { 11 | validate := validator.New() 12 | err := validate.Struct(obj) 13 | if err == nil { 14 | return nil 15 | } 16 | validationErrors := err.(validator.ValidationErrors) 17 | validationError := validationErrors[0] 18 | 19 | field := strings.ToLower(validationError.StructField()) 20 | switch validationError.Tag() { 21 | case "required": 22 | return errors.New(field + " is required") 23 | case "max": 24 | return errors.New(field + " is required with max " + validationError.Param()) 25 | case "min": 26 | return errors.New(field + " is required with min " + validationError.Param()) 27 | case "email": 28 | return errors.New(field + " is invalid") 29 | } 30 | return nil 31 | } 32 | -------------------------------------------------------------------------------- /internal/test/internal-mock/campaign_service_mock.go: -------------------------------------------------------------------------------- 1 | package internalmock 2 | 3 | import ( 4 | "emailn/internal/contract" 5 | 6 | "github.com/stretchr/testify/mock" 7 | ) 8 | 9 | type CampaignServiceMock struct { 10 | mock.Mock 11 | } 12 | 13 | func (r *CampaignServiceMock) Create(newCampaign contract.NewCampaign) (string, error) { 14 | args := r.Called(newCampaign) 15 | return args.String(0), args.Error(1) 16 | } 17 | 18 | func (r *CampaignServiceMock) GetBy(id string) (*contract.CampaignResponse, error) { 19 | args := r.Called(id) 20 | if args.Error(1) != nil { 21 | return nil, args.Error(1) 22 | } 23 | return args.Get(0).(*contract.CampaignResponse), args.Error(1) 24 | } 25 | 26 | func (r *CampaignServiceMock) Delete(id string) error { 27 | args := r.Called(id) 28 | return args.Error(0) 29 | } 30 | 31 | func (r *CampaignServiceMock) Start(id string) error { 32 | args := r.Called(id) 33 | return args.Error(0) 34 | } 35 | -------------------------------------------------------------------------------- /internal/endpoints/campaigns_start_test.go: -------------------------------------------------------------------------------- 1 | package endpoints 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/mock" 9 | ) 10 | 11 | func Test_CampaignsStart_200(t *testing.T) { 12 | setup() 13 | campaignId := "xpto" 14 | service.On("Start", mock.MatchedBy(func(id string) bool { 15 | return id == campaignId 16 | })).Return(nil) 17 | req, rr := newHttpTest("PATCH", "/", nil) 18 | req = addParameter(req, "id", campaignId) 19 | 20 | _, status, err := handler.CampaignStart(rr, req) 21 | 22 | assert.Equal(t, 200, status) 23 | assert.Nil(t, err) 24 | } 25 | 26 | func Test_CampaignsStart_Err(t *testing.T) { 27 | setup() 28 | errExpected := errors.New("something wrong") 29 | service.On("Start", mock.Anything).Return(errExpected) 30 | req, rr := newHttpTest("PATCH", "/", nil) 31 | 32 | _, _, err := handler.CampaignStart(rr, req) 33 | 34 | assert.Equal(t, errExpected, err) 35 | } 36 | -------------------------------------------------------------------------------- /internal/endpoints/handler_error.go: -------------------------------------------------------------------------------- 1 | package endpoints 2 | 3 | import ( 4 | internalerrors "emailn/internal/internal-errors" 5 | "errors" 6 | "net/http" 7 | 8 | "github.com/go-chi/render" 9 | "gorm.io/gorm" 10 | ) 11 | 12 | type EndpointFunc func(w http.ResponseWriter, r *http.Request) (interface{}, int, error) 13 | 14 | func HandlerError(endpointFunc EndpointFunc) http.HandlerFunc { 15 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 16 | obj, status, err := endpointFunc(w, r) 17 | if err != nil { 18 | 19 | if errors.Is(err, internalerrors.ErrInternal) { 20 | render.Status(r, 500) 21 | } else if errors.Is(err, gorm.ErrRecordNotFound) { 22 | render.Status(r, 404) 23 | } else { 24 | render.Status(r, 400) 25 | } 26 | render.JSON(w, r, map[string]string{"error": err.Error()}) 27 | return 28 | } 29 | render.Status(r, status) 30 | 31 | if obj != nil { 32 | render.JSON(w, r, obj) 33 | } 34 | }) 35 | } 36 | -------------------------------------------------------------------------------- /internal/endpoints/campaigns_delete_test.go: -------------------------------------------------------------------------------- 1 | package endpoints 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/mock" 9 | ) 10 | 11 | func Test_CampaignsDelete_200(t *testing.T) { 12 | setup() 13 | campaignId := "xpto" 14 | service.On("Delete", mock.MatchedBy(func(id string) bool { 15 | return id == campaignId 16 | })).Return(nil) 17 | req, rr := newHttpTest("PATCH", "/", nil) 18 | req = addParameter(req, "id", campaignId) 19 | 20 | _, status, err := handler.CampaignDelete(rr, req) 21 | 22 | assert.Equal(t, 200, status) 23 | assert.Nil(t, err) 24 | } 25 | 26 | func Test_CampaignsDelete_Err(t *testing.T) { 27 | setup() 28 | errExpected := errors.New("something wrong") 29 | service.On("Delete", mock.Anything).Return(errExpected) 30 | req, rr := newHttpTest("PATCH", "/", nil) 31 | 32 | _, _, err := handler.CampaignDelete(rr, req) 33 | 34 | assert.Equal(t, errExpected, err) 35 | } 36 | -------------------------------------------------------------------------------- /.air.toml: -------------------------------------------------------------------------------- 1 | root = "." 2 | testdata_dir = "testdata" 3 | tmp_dir = "tmp" 4 | 5 | [build] 6 | args_bin = [] 7 | bin = "tmp\\main.exe" 8 | cmd = "go build -o ./tmp/main.exe ./cmd/api/main.go" 9 | delay = 0 10 | exclude_dir = ["assets", "tmp", "vendor", "testdata"] 11 | exclude_file = [] 12 | exclude_regex = ["_test.go"] 13 | exclude_unchanged = false 14 | follow_symlink = false 15 | full_bin = "" 16 | include_dir = [] 17 | include_ext = ["go", "tpl", "tmpl", "html"] 18 | include_file = [] 19 | kill_delay = "0s" 20 | log = "build-errors.log" 21 | rerun = false 22 | rerun_delay = 500 23 | send_interrupt = false 24 | stop_on_error = false 25 | 26 | [color] 27 | app = "" 28 | build = "yellow" 29 | main = "magenta" 30 | runner = "green" 31 | watcher = "cyan" 32 | 33 | [log] 34 | main_only = false 35 | time = false 36 | 37 | [misc] 38 | clean_on_exit = false 39 | 40 | [screen] 41 | clear_on_rebuild = false 42 | keep_scroll = true 43 | -------------------------------------------------------------------------------- /cmd/worker/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "emailn/internal/domain/campaign" 5 | "emailn/internal/infrastructure/database" 6 | "emailn/internal/infrastructure/mail" 7 | "log" 8 | "time" 9 | 10 | "github.com/joho/godotenv" 11 | ) 12 | 13 | func main() { 14 | println("Started worker") 15 | err := godotenv.Load("../../.env") 16 | if err != nil { 17 | log.Fatal("Error loading .env file") 18 | } 19 | 20 | db := database.NewDb() 21 | repository := database.CampaignRepository{Db: db} 22 | campaignService := campaign.ServiceImp{ 23 | Repository: &repository, 24 | SendMail: mail.SendMail, 25 | } 26 | 27 | for { 28 | campaigns, err := repository.GetCampaignsToBeSent() 29 | 30 | if err != nil { 31 | println(err.Error()) 32 | } 33 | 34 | println("Amount of campaigns: ", len(campaigns)) 35 | 36 | for _, campaign := range campaigns { 37 | campaignService.SendEmailAndUpdateStatus(&campaign) 38 | println("Campaign sent: ", campaign.ID) 39 | } 40 | 41 | time.Sleep(10 * time.Second) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /internal/test/internal-mock/campaign_repository_mock.go: -------------------------------------------------------------------------------- 1 | package internalmock 2 | 3 | import ( 4 | "emailn/internal/domain/campaign" 5 | 6 | "github.com/stretchr/testify/mock" 7 | ) 8 | 9 | type CampaignRepositoryMock struct { 10 | mock.Mock 11 | } 12 | 13 | func (r *CampaignRepositoryMock) Create(campaign *campaign.Campaign) error { 14 | args := r.Called(campaign) 15 | return args.Error(0) 16 | } 17 | 18 | func (r *CampaignRepositoryMock) Update(campaign *campaign.Campaign) error { 19 | args := r.Called(campaign) 20 | return args.Error(0) 21 | } 22 | 23 | func (r *CampaignRepositoryMock) Get() ([]campaign.Campaign, error) { 24 | //args := r.Called(campaign) 25 | return nil, nil 26 | } 27 | 28 | func (r *CampaignRepositoryMock) GetBy(id string) (*campaign.Campaign, error) { 29 | args := r.Called(id) 30 | if args.Error(1) != nil { 31 | return nil, args.Error(1) 32 | } 33 | return args.Get(0).(*campaign.Campaign), nil 34 | } 35 | 36 | func (r *CampaignRepositoryMock) Delete(campaign *campaign.Campaign) error { 37 | args := r.Called(campaign) 38 | return args.Error(0) 39 | } 40 | -------------------------------------------------------------------------------- /docs/rest.http: -------------------------------------------------------------------------------- 1 | @url = http://localhost:3000 2 | @identity_provider = http://localhost:8080 3 | 4 | ### 5 | 6 | GET {{url}}/ping 7 | 8 | ### 9 | # @name campaign_create 10 | POST {{url}}/campaigns 11 | Authorization: Bearer {{access_token}} 12 | 13 | { 14 | "Name": "Hi Henrique!", 15 | "Content": "Hello!", 16 | "emails": ["salmeidabatista@gmail.com", "henrique@teste.com.br", "henrique2@teste.com.br", "henrique@teste.com.br"] 17 | } 18 | 19 | ### 20 | @campaign_id = {{campaign_create.response.body.id}} 21 | 22 | ### 23 | 24 | GET {{url}}/campaigns/{{campaign_id}} 25 | Authorization: Bearer {{access_token}} 26 | 27 | ### 28 | 29 | PATCH {{url}}/campaigns/start/{{campaign_id}} 30 | Authorization: Bearer {{access_token}} 31 | 32 | ### 33 | 34 | DELETE {{url}}/campaigns/delete/{{campaign_id}} 35 | Authorization: Bearer {{access_token}} 36 | 37 | ### 38 | # @name token 39 | POST {{identity_provider}}/realms/provider/protocol/openid-connect/token 40 | Content-Type: application/x-www-form-urlencoded 41 | 42 | client_id=emailn&username=salmeidabatista@gmail.com&password=123456&grant_type=password 43 | 44 | ### 45 | @access_token = {{token.response.body.access_token}} 46 | 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /internal/endpoints/campaigns_get_by_id_test.go: -------------------------------------------------------------------------------- 1 | package endpoints 2 | 3 | import ( 4 | "emailn/internal/contract" 5 | "errors" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/mock" 10 | ) 11 | 12 | func Test_CampaignsGetById_Campaign(t *testing.T) { 13 | setup() 14 | campaignId := "343" 15 | campaign := contract.CampaignResponse{ 16 | ID: campaignId, 17 | Name: "Test", 18 | Content: "Hi!", 19 | Status: "Pending", 20 | } 21 | service.On("GetBy", mock.Anything).Return(&campaign, nil) 22 | req, rr := newHttpTest("GET", "/", nil) 23 | req = addParameter(req, "id", campaignId) 24 | 25 | response, status, _ := handler.CampaignGetById(rr, req) 26 | 27 | assert.Equal(t, 200, status) 28 | assert.Equal(t, campaign.ID, response.(*contract.CampaignResponse).ID) 29 | assert.Equal(t, campaign.Name, response.(*contract.CampaignResponse).Name) 30 | } 31 | 32 | func Test_CampaignsGetById_Err(t *testing.T) { 33 | setup() 34 | errExpected := errors.New("something wrong") 35 | service.On("GetBy", mock.Anything).Return(nil, errExpected) 36 | req, rr := newHttpTest("GET", "/", nil) 37 | 38 | _, _, errReturned := handler.CampaignGetById(rr, req) 39 | 40 | assert.Equal(t, errExpected.Error(), errReturned.Error()) 41 | } 42 | -------------------------------------------------------------------------------- /internal/endpoints/setup_test.go: -------------------------------------------------------------------------------- 1 | package endpoints 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | internalmock "emailn/internal/test/internal-mock" 7 | "encoding/json" 8 | "net/http" 9 | "net/http/httptest" 10 | 11 | "github.com/go-chi/chi/v5" 12 | ) 13 | 14 | var ( 15 | service *internalmock.CampaignServiceMock 16 | handler = Handler{} 17 | ) 18 | 19 | func setup() { 20 | service = new(internalmock.CampaignServiceMock) 21 | handler.CampaignService = service 22 | } 23 | 24 | func newHttpTest(method string, url string, body interface{}) (*http.Request, *httptest.ResponseRecorder) { 25 | 26 | var buf bytes.Buffer 27 | if body != nil { 28 | json.NewEncoder(&buf).Encode(body) 29 | } 30 | req, _ := http.NewRequest(method, url, &buf) 31 | rr := httptest.NewRecorder() 32 | return req, rr 33 | } 34 | 35 | func addParameter(req *http.Request, keyParameter string, valueParameter string) *http.Request { 36 | chiContext := chi.NewRouteContext() 37 | chiContext.URLParams.Add(keyParameter, valueParameter) 38 | return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, chiContext)) 39 | } 40 | 41 | func addContext(req *http.Request, keyParameter string, valueParameter string) *http.Request { 42 | ctx := context.WithValue(req.Context(), keyParameter, valueParameter) 43 | return req.WithContext(ctx) 44 | } 45 | -------------------------------------------------------------------------------- /internal/endpoints/campaigns_post_test.go: -------------------------------------------------------------------------------- 1 | package endpoints 2 | 3 | import ( 4 | "emailn/internal/contract" 5 | "fmt" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/mock" 10 | ) 11 | 12 | var ( 13 | createdByExpected = "teste1@teste.com.br" 14 | body = contract.NewCampaign{ 15 | Name: "teste", 16 | Content: "Hi everyone", 17 | Emails: []string{"teste@teste.com"}, 18 | } 19 | ) 20 | 21 | func Test_CampaignsPost_201(t *testing.T) { 22 | setup() 23 | service.On("Create", mock.MatchedBy(func(request contract.NewCampaign) bool { 24 | if request.Name == body.Name && 25 | request.Content == body.Content && 26 | request.CreatedBy == createdByExpected { 27 | return true 28 | } else { 29 | return false 30 | } 31 | })).Return("34x", nil) 32 | req, rr := newHttpTest("POST", "/", body) 33 | req = addContext(req, "email", createdByExpected) 34 | 35 | _, status, err := handler.CampaignPost(rr, req) 36 | 37 | assert.Equal(t, 201, status) 38 | assert.Nil(t, err) 39 | } 40 | 41 | func Test_CampaignsPost_Err(t *testing.T) { 42 | setup() 43 | service.On("Create", mock.Anything).Return("", fmt.Errorf("error")) 44 | req, rr := newHttpTest("POST", "/", body) 45 | req = addContext(req, "email", createdByExpected) 46 | 47 | _, _, err := handler.CampaignPost(rr, req) 48 | 49 | assert.NotNil(t, err) 50 | } 51 | -------------------------------------------------------------------------------- /internal/infrastructure/database/campaign_repository.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "emailn/internal/domain/campaign" 5 | 6 | "gorm.io/gorm" 7 | ) 8 | 9 | type CampaignRepository struct { 10 | Db *gorm.DB 11 | } 12 | 13 | func (c *CampaignRepository) Create(campaign *campaign.Campaign) error { 14 | tx := c.Db.Create(campaign) 15 | return tx.Error 16 | } 17 | 18 | func (c *CampaignRepository) Update(campaign *campaign.Campaign) error { 19 | tx := c.Db.Save(campaign) 20 | return tx.Error 21 | } 22 | 23 | func (c *CampaignRepository) Get() ([]campaign.Campaign, error) { 24 | var campaigns []campaign.Campaign 25 | tx := c.Db.Find(&campaigns) 26 | return campaigns, tx.Error 27 | } 28 | 29 | func (c *CampaignRepository) GetBy(id string) (*campaign.Campaign, error) { 30 | var campaign campaign.Campaign 31 | tx := c.Db.Preload("Contacts").First(&campaign, "id = ?", id) 32 | return &campaign, tx.Error 33 | } 34 | 35 | func (c *CampaignRepository) Delete(campaign *campaign.Campaign) error { 36 | 37 | tx := c.Db.Select("Contacts").Delete(campaign) 38 | return tx.Error 39 | } 40 | 41 | func (c *CampaignRepository) GetCampaignsToBeSent() ([]campaign.Campaign, error) { 42 | var campaigns []campaign.Campaign 43 | tx := c.Db.Preload("Contacts").Find( 44 | &campaigns, 45 | "status = ? and date_part('minute', now()::timestamp - updated_on::timestamp) >= ?", 46 | campaign.Started, 47 | 1) 48 | return campaigns, tx.Error 49 | } 50 | -------------------------------------------------------------------------------- /cmd/api/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "emailn/internal/domain/campaign" 5 | "emailn/internal/endpoints" 6 | "emailn/internal/infrastructure/database" 7 | "emailn/internal/infrastructure/mail" 8 | "log" 9 | "net/http" 10 | 11 | "github.com/go-chi/chi/v5" 12 | "github.com/go-chi/chi/v5/middleware" 13 | "github.com/joho/godotenv" 14 | ) 15 | 16 | func main() { 17 | 18 | err := godotenv.Load("../../.env") 19 | if err != nil { 20 | log.Fatal("Error loading .env file") 21 | } 22 | 23 | r := chi.NewRouter() 24 | 25 | r.Use(middleware.RequestID) 26 | r.Use(middleware.RealIP) 27 | r.Use(middleware.Logger) 28 | r.Use(middleware.Recoverer) 29 | 30 | db := database.NewDb() 31 | campaignService := campaign.ServiceImp{ 32 | Repository: &database.CampaignRepository{Db: db}, 33 | SendMail: mail.SendMail, 34 | } 35 | handler := endpoints.Handler{ 36 | CampaignService: &campaignService, 37 | } 38 | r.Get("/ping", func(w http.ResponseWriter, r *http.Request) { 39 | w.Write([]byte("pong")) 40 | }) 41 | 42 | r.Route("/campaigns", func(r chi.Router) { 43 | r.Use(endpoints.Auth) 44 | r.Post("/", endpoints.HandlerError(handler.CampaignPost)) 45 | r.Get("/{id}", endpoints.HandlerError(handler.CampaignGetById)) 46 | r.Delete("/delete/{id}", endpoints.HandlerError(handler.CampaignDelete)) 47 | r.Patch("/start/{id}", endpoints.HandlerError(handler.CampaignStart)) 48 | }) 49 | 50 | http.ListenAndServe(":3000", r) 51 | } 52 | -------------------------------------------------------------------------------- /internal/endpoints/auth.go: -------------------------------------------------------------------------------- 1 | package endpoints 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "os" 7 | "strings" 8 | 9 | oidc "github.com/coreos/go-oidc/v3/oidc" 10 | jwtgo "github.com/dgrijalva/jwt-go" 11 | "github.com/go-chi/render" 12 | ) 13 | 14 | func Auth(next http.Handler) http.Handler { 15 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 16 | 17 | tokenString := r.Header.Get("Authorization") 18 | if tokenString == "" { 19 | render.Status(r, 401) 20 | render.JSON(w, r, map[string]string{"error": "request does not contain an authorization header"}) 21 | return 22 | } 23 | 24 | tokenString = strings.Replace(tokenString, "Bearer ", "", 1) 25 | provider, err := oidc.NewProvider(r.Context(), os.Getenv("KEYCLOAK")) 26 | if err != nil { 27 | render.Status(r, 500) 28 | render.JSON(w, r, map[string]string{"error": "error to connect to the provider"}) 29 | return 30 | } 31 | 32 | verifier := provider.Verifier(&oidc.Config{ClientID: "emailn"}) 33 | _, err = verifier.Verify(r.Context(), tokenString) 34 | if err != nil { 35 | render.Status(r, 401) 36 | render.JSON(w, r, map[string]string{"error": "invalid token"}) 37 | return 38 | } 39 | 40 | token, _ := jwtgo.Parse(tokenString, nil) 41 | claims := token.Claims.(jwtgo.MapClaims) 42 | email := claims["email"] 43 | 44 | ctx := context.WithValue(r.Context(), "email", email) 45 | next.ServeHTTP(w, r.WithContext(ctx)) 46 | }) 47 | } 48 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module emailn 2 | 3 | go 1.19 4 | 5 | require ( 6 | github.com/go-chi/chi/v5 v5.0.8 7 | github.com/go-chi/render v1.0.2 8 | github.com/go-playground/validator/v10 v10.11.1 9 | github.com/jaswdr/faker v1.16.0 10 | github.com/rs/xid v1.4.0 11 | github.com/stretchr/testify v1.8.1 12 | ) 13 | 14 | require ( 15 | github.com/ajg/form v1.5.1 // indirect 16 | github.com/coreos/go-oidc/v3 v3.6.0 // indirect 17 | github.com/davecgh/go-spew v1.1.1 // indirect 18 | github.com/dgrijalva/jwt-go v3.2.0+incompatible // indirect 19 | github.com/go-jose/go-jose/v3 v3.0.0 // indirect 20 | github.com/go-playground/locales v0.14.0 // indirect 21 | github.com/go-playground/universal-translator v0.18.0 // indirect 22 | github.com/golang-jwt/jwt v3.2.2+incompatible // indirect 23 | github.com/golang/protobuf v1.5.2 // indirect 24 | github.com/jackc/pgpassfile v1.0.0 // indirect 25 | github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect 26 | github.com/jackc/pgx/v5 v5.3.1 // indirect 27 | github.com/jinzhu/inflection v1.0.0 // indirect 28 | github.com/jinzhu/now v1.1.5 // indirect 29 | github.com/joho/godotenv v1.5.1 // indirect 30 | github.com/leodido/go-urn v1.2.1 // indirect 31 | github.com/pmezard/go-difflib v1.0.0 // indirect 32 | github.com/stretchr/objx v0.5.0 // indirect 33 | golang.org/x/crypto v0.7.0 // indirect 34 | golang.org/x/net v0.8.0 // indirect 35 | golang.org/x/oauth2 v0.6.0 // indirect 36 | golang.org/x/sys v0.6.0 // indirect 37 | golang.org/x/text v0.8.0 // indirect 38 | google.golang.org/appengine v1.6.7 // indirect 39 | google.golang.org/protobuf v1.28.0 // indirect 40 | gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect 41 | gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df // indirect 42 | gopkg.in/yaml.v3 v3.0.1 // indirect 43 | gorm.io/driver/postgres v1.5.0 // indirect 44 | gorm.io/gorm v1.24.7-0.20230306060331-85eaf9eeda11 // indirect 45 | ) 46 | -------------------------------------------------------------------------------- /internal/endpoints/handler_error_test.go: -------------------------------------------------------------------------------- 1 | package endpoints 2 | 3 | import ( 4 | internalerrors "emailn/internal/internal-errors" 5 | "encoding/json" 6 | "errors" 7 | "net/http" 8 | "net/http/httptest" 9 | "testing" 10 | 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | func Test_HandlerError_when_endpoint_returns_internal_error(t *testing.T) { 15 | assert := assert.New(t) 16 | endpoint := func(w http.ResponseWriter, r *http.Request) (interface{}, int, error) { 17 | return nil, 0, internalerrors.ErrInternal 18 | } 19 | handlerFunc := HandlerError(endpoint) 20 | req, _ := http.NewRequest("GET", "/", nil) 21 | res := httptest.NewRecorder() 22 | 23 | handlerFunc.ServeHTTP(res, req) 24 | 25 | assert.Equal(http.StatusInternalServerError, res.Code) 26 | assert.Contains(res.Body.String(), internalerrors.ErrInternal.Error()) 27 | } 28 | 29 | func Test_HandlerError_when_endpoint_returns_domain_error(t *testing.T) { 30 | assert := assert.New(t) 31 | endpoint := func(w http.ResponseWriter, r *http.Request) (interface{}, int, error) { 32 | return nil, 0, errors.New("domain error") 33 | } 34 | handlerFunc := HandlerError(endpoint) 35 | req, _ := http.NewRequest("GET", "/", nil) 36 | res := httptest.NewRecorder() 37 | 38 | handlerFunc.ServeHTTP(res, req) 39 | 40 | assert.Equal(http.StatusBadRequest, res.Code) 41 | assert.Contains(res.Body.String(), "domain error") 42 | } 43 | 44 | func Test_HandlerError_when_endpoint_returns_obj_and_status(t *testing.T) { 45 | assert := assert.New(t) 46 | type bodyForTest struct { 47 | Id int 48 | } 49 | objExpected := bodyForTest{Id: 2} 50 | endpoint := func(w http.ResponseWriter, r *http.Request) (interface{}, int, error) { 51 | return objExpected, 201, nil 52 | } 53 | handlerFunc := HandlerError(endpoint) 54 | req, _ := http.NewRequest("GET", "/", nil) 55 | res := httptest.NewRecorder() 56 | 57 | handlerFunc.ServeHTTP(res, req) 58 | 59 | assert.Equal(http.StatusCreated, res.Code) 60 | objReturned := bodyForTest{} 61 | json.Unmarshal(res.Body.Bytes(), &objReturned) 62 | assert.Equal(objExpected, objReturned) 63 | } 64 | -------------------------------------------------------------------------------- /internal/domain/campaign/campaign.go: -------------------------------------------------------------------------------- 1 | package campaign 2 | 3 | import ( 4 | internalerrors "emailn/internal/internal-errors" 5 | "time" 6 | 7 | "github.com/rs/xid" 8 | ) 9 | 10 | const ( 11 | Pending string = "Pending" 12 | Canceled = "Canceled" 13 | Deleted = "Deleted" 14 | Started = "Started" 15 | Fail = "Fail" 16 | Done = "Done" 17 | ) 18 | 19 | type Contact struct { 20 | ID string `gorm:"size:50"` 21 | Email string `validate:"email" gorm:"size:100"` 22 | CampaignId string `gorm:"size:50"` 23 | } 24 | 25 | type Campaign struct { 26 | ID string `validate:"required" gorm:"size:50;not null"` 27 | Name string `validate:"min=5,max=24" gorm:"size:100;not null"` 28 | CreatedOn time.Time `validate:"required" gorm:"not null"` 29 | UpdatedOn time.Time 30 | Content string `validate:"min=5,max=1024" gorm:"size:1024;not null"` 31 | Contacts []Contact `validate:"min=1,dive"` 32 | Status string `gorm:"size:20;not null"` 33 | CreatedBy string `validate:"email" gorm:"size:50;not null"` 34 | } 35 | 36 | // TODO: make unit test 37 | func (c *Campaign) Done() { 38 | c.Status = Done 39 | c.UpdatedOn = time.Now() 40 | } 41 | 42 | // TODO: make unit test 43 | func (c *Campaign) Cancel() { 44 | c.Status = Canceled 45 | c.UpdatedOn = time.Now() 46 | } 47 | 48 | // TODO: make unit test 49 | func (c *Campaign) Delete() { 50 | c.Status = Deleted 51 | c.UpdatedOn = time.Now() 52 | } 53 | 54 | // TODO: make unit test 55 | func (c *Campaign) Fail() { 56 | c.Status = Fail 57 | c.UpdatedOn = time.Now() 58 | } 59 | 60 | // TODO: make unit test 61 | func (c *Campaign) Started() { 62 | c.Status = Started 63 | c.UpdatedOn = time.Now() 64 | } 65 | 66 | func NewCampaign(name string, content string, emails []string, createdBy string) (*Campaign, error) { 67 | 68 | contacts := make([]Contact, len(emails)) 69 | for index, email := range emails { 70 | contacts[index].Email = email 71 | contacts[index].ID = xid.New().String() 72 | } 73 | 74 | campaign := &Campaign{ 75 | ID: xid.New().String(), 76 | Name: name, 77 | Content: content, 78 | CreatedOn: time.Now(), 79 | Contacts: contacts, 80 | Status: Pending, 81 | CreatedBy: createdBy, 82 | } 83 | err := internalerrors.ValidateStruct(campaign) 84 | if err == nil { 85 | return campaign, nil 86 | } 87 | return nil, err 88 | } 89 | -------------------------------------------------------------------------------- /internal/domain/campaign/service.go: -------------------------------------------------------------------------------- 1 | package campaign 2 | 3 | import ( 4 | "emailn/internal/contract" 5 | internalerrors "emailn/internal/internal-errors" 6 | "errors" 7 | ) 8 | 9 | type Service interface { 10 | Create(newCampaign contract.NewCampaign) (string, error) 11 | GetBy(id string) (*contract.CampaignResponse, error) 12 | Delete(id string) error 13 | Start(id string) error 14 | } 15 | 16 | type ServiceImp struct { 17 | Repository Repository 18 | SendMail func(campaign *Campaign) error 19 | } 20 | 21 | func (s *ServiceImp) Create(newCampaign contract.NewCampaign) (string, error) { 22 | 23 | //TODO: fix the arg createdBy 24 | campaign, err := NewCampaign(newCampaign.Name, newCampaign.Content, newCampaign.Emails, newCampaign.CreatedBy) 25 | if err != nil { 26 | return "", err 27 | } 28 | err = s.Repository.Create(campaign) 29 | if err != nil { 30 | return "", internalerrors.ErrInternal 31 | } 32 | 33 | return campaign.ID, nil 34 | } 35 | 36 | func (s *ServiceImp) GetBy(id string) (*contract.CampaignResponse, error) { 37 | 38 | campaign, err := s.Repository.GetBy(id) 39 | 40 | if err != nil { 41 | return nil, internalerrors.ProcessErrorToReturn(err) 42 | } 43 | 44 | return &contract.CampaignResponse{ 45 | ID: campaign.ID, 46 | Name: campaign.Name, 47 | Content: campaign.Content, 48 | Status: campaign.Status, 49 | AmountOfEmailsToSend: len(campaign.Contacts), 50 | CreatedBy: campaign.CreatedBy, 51 | }, nil 52 | } 53 | 54 | func (s *ServiceImp) Delete(id string) error { 55 | 56 | campaignSaved, err := s.getAndValidateStatusIsPending(id) 57 | 58 | if err != nil { 59 | return err 60 | } 61 | 62 | campaignSaved.Delete() 63 | err = s.Repository.Delete(campaignSaved) 64 | if err != nil { 65 | return internalerrors.ErrInternal 66 | } 67 | 68 | return nil 69 | } 70 | 71 | // TODO make unit test 72 | func (s *ServiceImp) SendEmailAndUpdateStatus(campaignSaved *Campaign) { 73 | err := s.SendMail(campaignSaved) 74 | if err != nil { 75 | campaignSaved.Fail() 76 | } else { 77 | campaignSaved.Done() 78 | } 79 | s.Repository.Update(campaignSaved) 80 | } 81 | 82 | // TODO make unit test 83 | func (s *ServiceImp) Start(id string) error { 84 | 85 | campaignSaved, err := s.getAndValidateStatusIsPending(id) 86 | 87 | if err != nil { 88 | return err 89 | } 90 | 91 | go s.SendEmailAndUpdateStatus(campaignSaved) 92 | 93 | campaignSaved.Started() 94 | err = s.Repository.Update(campaignSaved) 95 | if err != nil { 96 | return internalerrors.ErrInternal 97 | } 98 | 99 | return nil 100 | } 101 | 102 | func (s *ServiceImp) getAndValidateStatusIsPending(id string) (*Campaign, error) { 103 | campaign, err := s.Repository.GetBy(id) 104 | 105 | if err != nil { 106 | return nil, internalerrors.ProcessErrorToReturn(err) 107 | } 108 | 109 | if campaign.Status != Pending { 110 | return nil, errors.New("Campaign status invalid") 111 | } 112 | return campaign, nil 113 | } 114 | -------------------------------------------------------------------------------- /internal/domain/campaign/campaign_test.go: -------------------------------------------------------------------------------- 1 | package campaign 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/jaswdr/faker" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | var ( 12 | name = "Campaign X" 13 | content = "Body Hi!" 14 | contacts = []string{"email1@e.com", "email2@e.com"} 15 | createdBy = "teste@teste.com.br" 16 | fake = faker.New() 17 | ) 18 | 19 | func Test_NewCampaign_CreateCampaign(t *testing.T) { 20 | assert := assert.New(t) 21 | 22 | campaign, _ := NewCampaign(name, content, contacts, createdBy) 23 | 24 | assert.Equal(campaign.Name, name) 25 | assert.Equal(campaign.Content, content) 26 | assert.Equal(len(campaign.Contacts), len(contacts)) 27 | assert.Equal(createdBy, campaign.CreatedBy) 28 | } 29 | 30 | func Test_NewCampaign_IDIsNotNill(t *testing.T) { 31 | assert := assert.New(t) 32 | 33 | campaign, _ := NewCampaign(name, content, contacts, createdBy) 34 | 35 | assert.NotNil(campaign.ID) 36 | } 37 | 38 | func Test_NewCampaign_MustStatusStartWithPending(t *testing.T) { 39 | assert := assert.New(t) 40 | 41 | campaign, _ := NewCampaign(name, content, contacts, createdBy) 42 | 43 | assert.Equal(Pending, campaign.Status) 44 | } 45 | 46 | func Test_NewCampaign_CreatedOnMustBeNow(t *testing.T) { 47 | assert := assert.New(t) 48 | now := time.Now().Add(-time.Minute) 49 | 50 | campaign, _ := NewCampaign(name, content, contacts, createdBy) 51 | 52 | assert.Greater(campaign.CreatedOn, now) 53 | } 54 | 55 | func Test_NewCampaign_MustValidateNameMin(t *testing.T) { 56 | assert := assert.New(t) 57 | 58 | _, err := NewCampaign("", content, contacts, createdBy) 59 | 60 | assert.Equal("name is required with min 5", err.Error()) 61 | } 62 | 63 | func Test_NewCampaign_MustValidateNameMax(t *testing.T) { 64 | assert := assert.New(t) 65 | 66 | _, err := NewCampaign(fake.Lorem().Text(30), content, contacts, createdBy) 67 | 68 | assert.Equal("name is required with max 24", err.Error()) 69 | } 70 | 71 | func Test_NewCampaign_MustValidateContentMin(t *testing.T) { 72 | assert := assert.New(t) 73 | 74 | _, err := NewCampaign(name, "", contacts, createdBy) 75 | 76 | assert.Equal("content is required with min 5", err.Error()) 77 | } 78 | 79 | func Test_NewCampaign_MustValidateContentMax(t *testing.T) { 80 | assert := assert.New(t) 81 | 82 | _, err := NewCampaign(name, fake.Lorem().Text(1040), contacts, createdBy) 83 | 84 | assert.Equal("content is required with max 1024", err.Error()) 85 | } 86 | 87 | func Test_NewCampaign_MustValidateContactsMin(t *testing.T) { 88 | assert := assert.New(t) 89 | 90 | _, err := NewCampaign(name, content, nil, createdBy) 91 | 92 | assert.Equal("contacts is required with min 1", err.Error()) 93 | } 94 | 95 | func Test_NewCampaign_MustValidateContacts(t *testing.T) { 96 | assert := assert.New(t) 97 | 98 | _, err := NewCampaign(name, content, []string{"email_invalid"}, createdBy) 99 | 100 | assert.Equal("email is invalid", err.Error()) 101 | } 102 | 103 | func Test_NewCampaign_MustValidateCreatedBy(t *testing.T) { 104 | assert := assert.New(t) 105 | 106 | _, err := NewCampaign(name, content, contacts, "") 107 | 108 | assert.Equal("createdby is invalid", err.Error()) 109 | } 110 | -------------------------------------------------------------------------------- /internal/domain/campaign/service_test.go: -------------------------------------------------------------------------------- 1 | package campaign_test 2 | 3 | import ( 4 | "emailn/internal/contract" 5 | "emailn/internal/domain/campaign" 6 | internalerrors "emailn/internal/internal-errors" 7 | internalmock "emailn/internal/test/internal-mock" 8 | "errors" 9 | "testing" 10 | 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/mock" 13 | "gorm.io/gorm" 14 | ) 15 | 16 | var ( 17 | newCampaign = contract.NewCampaign{ 18 | Name: "Test Y", 19 | Content: "Body Hi!", 20 | Emails: []string{"teste1@test.com"}, 21 | CreatedBy: "teste@teste.com.br", 22 | } 23 | campaignPending *campaign.Campaign 24 | campaignStarted *campaign.Campaign 25 | repositoryMock *internalmock.CampaignRepositoryMock 26 | service = campaign.ServiceImp{} 27 | ) 28 | 29 | func setUp() { 30 | repositoryMock = new(internalmock.CampaignRepositoryMock) 31 | service.Repository = repositoryMock 32 | campaignPending, _ = campaign.NewCampaign(newCampaign.Name, newCampaign.Content, newCampaign.Emails, newCampaign.CreatedBy) 33 | campaignStarted = &campaign.Campaign{ID: "1", Status: campaign.Started} 34 | } 35 | 36 | func setUpGetByIdRepositoryBy(campaign *campaign.Campaign) { 37 | repositoryMock.On("GetBy", mock.Anything).Return(campaign, nil) 38 | } 39 | 40 | func setUpUpdateRepository() { 41 | repositoryMock.On("Update", mock.Anything).Return(nil) 42 | } 43 | 44 | func setUpSendEmailWithSuccess() { 45 | sendMail := func(campaign *campaign.Campaign) error { 46 | return nil 47 | } 48 | service.SendMail = sendMail 49 | } 50 | 51 | //Method_Context_ReturnOrAction 52 | 53 | func Test_Create_RequestIsValid_IdIsNotNil(t *testing.T) { 54 | setUp() 55 | repositoryMock.On("Create", mock.Anything).Return(nil) 56 | 57 | id, err := service.Create(newCampaign) 58 | 59 | assert.NotNil(t, id) 60 | assert.Nil(t, err) 61 | } 62 | 63 | func Test_Create_RequestIsNotValid_ErrInternal(t *testing.T) { 64 | setUp() 65 | 66 | _, err := service.Create(contract.NewCampaign{}) 67 | 68 | assert.False(t, errors.Is(internalerrors.ErrInternal, err)) 69 | } 70 | 71 | func Test_Create_RequestIsValid_CallRepository(t *testing.T) { 72 | setUp() 73 | repositoryMock.On("Create", mock.MatchedBy(func(campaign *campaign.Campaign) bool { 74 | if campaign.Name != newCampaign.Name || 75 | campaign.Content != newCampaign.Content || 76 | len(campaign.Contacts) != len(newCampaign.Emails) { 77 | return false 78 | } 79 | 80 | return true 81 | })).Return(nil) 82 | 83 | service.Create(newCampaign) 84 | 85 | repositoryMock.AssertExpectations(t) 86 | } 87 | 88 | func Test_Create_ErrorOnRepository_ErrInternal(t *testing.T) { 89 | setUp() 90 | repositoryMock.On("Create", mock.Anything).Return(errors.New("error to save on database")) 91 | 92 | _, err := service.Create(newCampaign) 93 | 94 | assert.True(t, errors.Is(internalerrors.ErrInternal, err)) 95 | } 96 | 97 | func Test_GetById_CampaignExists_CampaignSaved(t *testing.T) { 98 | setUp() 99 | repositoryMock.On("GetBy", mock.MatchedBy(func(id string) bool { 100 | return id == campaignPending.ID 101 | })).Return(campaignPending, nil) 102 | 103 | campaignReturned, _ := service.GetBy(campaignPending.ID) 104 | 105 | assert.Equal(t, campaignPending.ID, campaignReturned.ID) 106 | assert.Equal(t, campaignPending.Name, campaignReturned.Name) 107 | assert.Equal(t, campaignPending.Content, campaignReturned.Content) 108 | assert.Equal(t, campaignPending.Status, campaignReturned.Status) 109 | assert.Equal(t, campaignPending.CreatedBy, campaignReturned.CreatedBy) 110 | } 111 | 112 | func Test_GetById_ErrorOnRepository_ErrInternal(t *testing.T) { 113 | setUp() 114 | repositoryMock.On("GetBy", mock.Anything).Return(nil, errors.New("Something wrong'")) 115 | 116 | _, err := service.GetBy("invalid_campaign") 117 | 118 | assert.Equal(t, internalerrors.ErrInternal.Error(), err.Error()) 119 | } 120 | 121 | func Test_Delete_CampaignWasNotFound_ErrRecordNotFound(t *testing.T) { 122 | setUp() 123 | repositoryMock.On("GetBy", mock.Anything).Return(nil, gorm.ErrRecordNotFound) 124 | 125 | err := service.Delete("invalid_campaign") 126 | 127 | assert.Equal(t, err.Error(), gorm.ErrRecordNotFound.Error()) 128 | } 129 | 130 | func Test_Delete_CampaignIsNotPending_Err(t *testing.T) { 131 | setUp() 132 | setUpGetByIdRepositoryBy(campaignStarted) 133 | 134 | err := service.Delete(campaignStarted.ID) 135 | 136 | assert.Equal(t, "Campaign status invalid", err.Error()) 137 | } 138 | 139 | func Test_Delete_ErrorOnRepository_ErrInternal(t *testing.T) { 140 | setUp() 141 | setUpGetByIdRepositoryBy(campaignPending) 142 | repositoryMock.On("Delete", mock.Anything).Return(errors.New("error to delete campaign")) 143 | 144 | err := service.Delete(campaignPending.ID) 145 | 146 | assert.Equal(t, internalerrors.ErrInternal.Error(), err.Error()) 147 | } 148 | 149 | func Test_Delete_CampaignWasDeleted_Nil(t *testing.T) { 150 | setUp() 151 | setUpGetByIdRepositoryBy(campaignPending) 152 | repositoryMock.On("Delete", mock.MatchedBy(func(campaign *campaign.Campaign) bool { 153 | return campaignPending == campaign 154 | })).Return(nil) 155 | 156 | err := service.Delete(campaignPending.ID) 157 | 158 | assert.Nil(t, err) 159 | } 160 | 161 | func Test_Start_CamapaignWasNotFound_ErrRecordNotFound(t *testing.T) { 162 | setUp() 163 | repositoryMock.On("GetBy", mock.Anything).Return(nil, gorm.ErrRecordNotFound) 164 | 165 | err := service.Start("invalid_campaign") 166 | 167 | assert.Equal(t, err.Error(), gorm.ErrRecordNotFound.Error()) 168 | } 169 | 170 | func Test_Start_CampaignIsNotPending_Err(t *testing.T) { 171 | setUp() 172 | setUpGetByIdRepositoryBy(campaignStarted) 173 | service.Repository = repositoryMock 174 | 175 | err := service.Start(campaignStarted.ID) 176 | 177 | assert.Equal(t, "Campaign status invalid", err.Error()) 178 | } 179 | 180 | func Test_Start_CampaignWasFound_SendEmail(t *testing.T) { 181 | setUp() 182 | setUpUpdateRepository() 183 | setUpGetByIdRepositoryBy(campaignPending) 184 | emailWasSent := false 185 | sendMail := func(campaign *campaign.Campaign) error { 186 | if campaign.ID == campaignPending.ID { 187 | emailWasSent = true 188 | } 189 | return nil 190 | } 191 | service.SendMail = sendMail 192 | 193 | service.Start(campaignPending.ID) 194 | 195 | assert.True(t, emailWasSent) 196 | } 197 | 198 | func Test_Start_SendEmailFailed_ErrInternal(t *testing.T) { 199 | setUp() 200 | setUpGetByIdRepositoryBy(campaignPending) 201 | sendMail := func(campaign *campaign.Campaign) error { 202 | return errors.New("error to send mail") 203 | } 204 | service.SendMail = sendMail 205 | 206 | err := service.Start(campaignPending.ID) 207 | 208 | assert.Equal(t, internalerrors.ErrInternal.Error(), err.Error()) 209 | } 210 | 211 | func Test_Start_CampaignWasUpdated_StatusIsDone(t *testing.T) { 212 | setUp() 213 | setUpSendEmailWithSuccess() 214 | setUpGetByIdRepositoryBy(campaignPending) 215 | repositoryMock.On("Update", mock.MatchedBy(func(campaignToUpdate *campaign.Campaign) bool { 216 | return campaignPending.ID == campaignToUpdate.ID && campaignToUpdate.Status == campaign.Done 217 | })).Return(nil) 218 | 219 | service.Start(campaignPending.ID) 220 | 221 | assert.Equal(t, campaign.Done, campaignPending.Status) 222 | } 223 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU= 2 | github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= 3 | github.com/coreos/go-oidc/v3 v3.6.0 h1:AKVxfYw1Gmkn/w96z0DbT/B/xFnzTd3MkZvWLjF4n/o= 4 | github.com/coreos/go-oidc/v3 v3.6.0/go.mod h1:ZpHUsHBucTUj6WOkrP4E20UPynbLZzhTQ1XKCXkxyPc= 5 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 6 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 7 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 8 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 9 | github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= 10 | github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= 11 | github.com/go-chi/chi/v5 v5.0.8 h1:lD+NLqFcAi1ovnVZpsnObHGW4xb4J8lNmoYVfECH1Y0= 12 | github.com/go-chi/chi/v5 v5.0.8/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= 13 | github.com/go-chi/render v1.0.2 h1:4ER/udB0+fMWB2Jlf15RV3F4A2FDuYi/9f+lFttR/Lg= 14 | github.com/go-chi/render v1.0.2/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0= 15 | github.com/go-jose/go-jose/v3 v3.0.0 h1:s6rrhirfEP/CGIoc6p+PZAeogN2SxKav6Wp7+dyMWVo= 16 | github.com/go-jose/go-jose/v3 v3.0.0/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8= 17 | github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A= 18 | github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= 19 | github.com/go-playground/locales v0.14.0 h1:u50s323jtVGugKlcYeyzC0etD1HifMjqmJqb8WugfUU= 20 | github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs= 21 | github.com/go-playground/universal-translator v0.18.0 h1:82dyy6p4OuJq4/CByFNOn/jYrnRPArHwAcmLoJZxyho= 22 | github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA= 23 | github.com/go-playground/validator/v10 v10.11.1 h1:prmOlTVv+YjZjmRmNSF3VmspqJIxJWXmqUsHwfTRRkQ= 24 | github.com/go-playground/validator/v10 v10.11.1/go.mod h1:i+3WkQ1FvaUjjxh1kSvIA4dMGDBiPU55YFDl0WbKdWU= 25 | github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= 26 | github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= 27 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 28 | github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 29 | github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= 30 | github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 31 | github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 32 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 33 | github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= 34 | github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= 35 | github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= 36 | github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= 37 | github.com/jackc/pgx/v5 v5.3.0/go.mod h1:t3JDKnCBlYIc0ewLF0Q7B8MXmoIaBOZj/ic7iHozM/8= 38 | github.com/jackc/pgx/v5 v5.3.1 h1:Fcr8QJ1ZeLi5zsPZqQeUZhNhxfkkKBOgJuYkJHoBOtU= 39 | github.com/jackc/pgx/v5 v5.3.1/go.mod h1:t3JDKnCBlYIc0ewLF0Q7B8MXmoIaBOZj/ic7iHozM/8= 40 | github.com/jackc/puddle/v2 v2.2.0/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= 41 | github.com/jaswdr/faker v1.16.0 h1:5ZjusQbqIZwJnUymPirNKJI1yFCuozdSR9oeYPgD5Uk= 42 | github.com/jaswdr/faker v1.16.0/go.mod h1:x7ZlyB1AZqwqKZgyQlnqEG8FDptmHlncA5u2zY/yi6w= 43 | github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= 44 | github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= 45 | github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= 46 | github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= 47 | github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 48 | github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 49 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 50 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 51 | github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= 52 | github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= 53 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 54 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 55 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 56 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 57 | github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w= 58 | github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= 59 | github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= 60 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 61 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 62 | github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= 63 | github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= 64 | github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= 65 | github.com/rs/xid v1.4.0 h1:qd7wPTDkN6KQx2VmMBLrpHkiyQwgFXRnkOLacUiaSNY= 66 | github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= 67 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 68 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 69 | github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= 70 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 71 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 72 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 73 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 74 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 75 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 76 | github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= 77 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 78 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 79 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 80 | golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 81 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 82 | golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3 h1:0es+/5331RGQPcXlMfP+WrnIIS6dNnNRe0WB02W0F4M= 83 | golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= 84 | golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= 85 | golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A= 86 | golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= 87 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 88 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 89 | golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= 90 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 91 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 92 | golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 93 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 94 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 95 | golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ= 96 | golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= 97 | golang.org/x/oauth2 v0.6.0 h1:Lh8GPgSKBfWSwFvtuWOfeI3aAAnbXTSutYxJiOJFgIw= 98 | golang.org/x/oauth2 v0.6.0/go.mod h1:ycmewcwgD4Rpr3eZJLSB4Kyyljb3qDh40vJ8STE5HKw= 99 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 100 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 101 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 102 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 103 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 104 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 105 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 106 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 107 | golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069 h1:siQdpVirKtzPhKl3lZWozZraCFObP8S1v6PRp0bLrtU= 108 | golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 109 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 110 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 111 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 112 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 113 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 114 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 115 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 116 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 117 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 118 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 119 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 120 | golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= 121 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 122 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 123 | golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68= 124 | golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 125 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 126 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 127 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 128 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 129 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 130 | google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= 131 | google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= 132 | google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= 133 | google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= 134 | google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw= 135 | google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= 136 | gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk= 137 | gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk= 138 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 139 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 140 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 141 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 142 | gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= 143 | gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE= 144 | gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw= 145 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 146 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 147 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 148 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 149 | gorm.io/driver/postgres v1.5.0 h1:u2FXTy14l45qc3UeCJ7QaAXZmZfDDv0YrthvmRq1l0U= 150 | gorm.io/driver/postgres v1.5.0/go.mod h1:FUZXzO+5Uqg5zzwzv4KK49R8lvGIyscBOqYrtI1Ce9A= 151 | gorm.io/gorm v1.24.6 h1:wy98aq9oFEetsc4CAbKD2SoBCdMzsbSIvSUUFJuHi5s= 152 | gorm.io/gorm v1.24.6/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k= 153 | gorm.io/gorm v1.24.7-0.20230306060331-85eaf9eeda11 h1:9qNbmu21nNThCNnF5i2R3kw2aL27U8ZwbzccNjOmW0g= 154 | gorm.io/gorm v1.24.7-0.20230306060331-85eaf9eeda11/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k= 155 | --------------------------------------------------------------------------------