Related
This bounty has ended. Answers to this question are eligible for a +50 reputation bounty. Bounty grace period ends in 21 hours.
Abhisek Roy wants to draw more attention to this question:
Looking for a workaround to the current issue.
I was implementing RDS IAM authentication from my Golang service and came across an issue. I am using the init function to call create a function where I have created a sync for every 10 mins where every 10 mins the token should get refreshed, it is creating the new token but it's not passing the same to the main function. When the existing thread that was initiated between DB and the service gets killed it is unable to re-authenticate the connection for the second time. Some observations that I have noticed were-
When initially deploying the application it is creating the connection to RDS successfully but after some time as soon as the initial thread between service and DB gets killed it's unable to authenticate the reason which I checked was- the address where the token is getting stored. While making an API call the service picks the token from a static address where the initial token gets stored at the time of service deployment. However, while the token gets refreshed every 10 mins its getting stored on dynamic addresses from where the service is unable to pick up the token.
Here is the go file where actually I am calling and creating the DB function-
package db
import (
"fmt"
"log"
"os"
"time"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/rds/rdsutils"
"github.com/go-co-op/gocron"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database/postgres"
_ "github.com/golang-migrate/migrate/v4/source/file"
"github.com/jinzhu/gorm"
_ "github.com/lib/pq"
)
const DatabaseVersion = 1
const Success = "Database connection successful"
const database = "postgres"
// Service ...
type Service interface {
InitDatabaseConnection(db **gorm.DB)
CreateDatabaseConnection(db **gorm.DB)
Migrate(db *gorm.DB) error
}
type service struct {
}
// NewService ...
func NewService() Service {
return &service{}
}
func (s *service) InitDatabaseConnection(db **gorm.DB) {
log.Print("Creating Db Connection")
s.CreateDatabaseConnection(db)
//todo: add if; if env for iam run the below code
// todo: change 1 min to 10 mins
startTime := time.Now().Local().Add(time.Minute + time.Duration(1))
sch := gocron.NewScheduler(time.UTC)
sch.Every(600).Seconds().StartAt(startTime).Do(func() {
log.Printf("refreshing rds creds")
s.CreateDatabaseConnection(db)
})
sch.StartAsync()
}
func (s *service) CreateDatabaseConnection(db **gorm.DB) {
configuration, _ := common.New()
var dbURI = ConnectDataBase(configuration.UserName, configuration.Password,
configuration.Port, configuration.DbName, configuration.Host, configuration.SearchPath)
var err error
*db, err = gorm.Open(database, dbURI)
if err != nil {
panic(err)
}
print("\n \n", dbURI)
print("\n \n in server.go \n \n ", &db)
if err != nil {
log.Println(err.Error())
os.Exit(3)
} else {
log.Println(Success)
}
(*db).DB().SetMaxOpenConns(6)
(*db).DB().SetConnMaxLifetime(600 * time.Second)
(*db).DB().SetMaxIdleConns(2)
(*db).DB().SetConnMaxIdleTime(100 * time.Second)
}
func ConnectDataBase(username string, password string, port int, dbName string, host string, searchPath string) string {
var dbURI string
if searchPath == "" {
searchPath = "public"
}
dbEndpoint := fmt.Sprintf("%s:%d", host, port)
sess := session.Must(session.NewSession())
creds := sess.Config.Credentials
authToken, err := rdsutils.BuildAuthToken(dbEndpoint, "us-east-1", username, creds)
print("\n \n", authToken)
if err != nil {
panic(err)
}
dbURI = fmt.Sprintf("host=%s port=%d user=%s dbname=%s sslmode=require password=%s search_path=%s",
host, port, username, dbName, authToken, searchPath) //Build connection string
// if password != "" && username != "" {
// dbURI = fmt.Sprintf("host=%s port=%d user=%s dbname=%s sslmode=require password=%s search_path=%s",
// host, port, username, dbName, authToken, searchPath) //Build connection string
// } else {
// dbURI = fmt.Sprintf("host=%s port=%d dbname=%s sslmode=disable ",
// host, port, dbName) //Build connection string
// }
return dbURI
}
func (s *service) Migrate(db *gorm.DB) error {
migrationSourceURL := "file://../resources/db/migrations/"
database := db.DB()
row := db.Table("schema_migrations").Limit(1).Row()
var version int8
var dirty bool
err := row.Scan(&version, &dirty)
if err == nil {
log.Printf("database is currently on version : %v \n", version)
}
log.Println("Migrating database to version ", DatabaseVersion)
driver, err := postgres.WithInstance(database, &postgres.Config{})
if err != nil {
return err
}
m, err := migrate.NewWithDatabaseInstance(migrationSourceURL, "postgres", driver)
if err != nil {
return err
}
m.Log = LogService{}
err = m.Migrate(uint(DatabaseVersion))
if err != nil {
log.Println(err.Error())
return err
}
return nil
}
The common property above is from this piece of code-
package common
import (
"fmt"
"log"
"os"
"github.com/spf13/viper"
)
type ApiResponse struct {
Code int `json:"code"`
Message string `json:"message"`
}
type Constants struct {
Port int `json:"port"`
Host string `json:"host"`
DbName string `json:"dbName"`
UserName string `json:"userName"`
Password string `json:"password"`
AuthUserName string `json:"authUserName"`
AuthPassword string `json:"authPassword"`
SearchPath string `json:"searchPath"`
AwsRegion string `json:"awsRegion"`
}
func New() (*Constants, error) {
fmt.Println("reached constants")
config := Constants{}
constants, err := initViper()
config = constants
if err != nil {
return &config, err
}
return &config, nil
}
func initViper() (Constants, error) {
// temporary, will be removed later when auto deploy is available
env := os.Getenv("CS_ENV")
var configName string
if env == "develop" {
configName = "config-develop"
} else {
configName = "config-local"
}
viper.SetConfigName(configName) // Constants fileName without the .TOML or .YAML extension
viper.AddConfigPath("../resources/config") // Search the root directory for the configuration file
err := viper.ReadInConfig() // Find and read the config file
if err != nil {
log.Println(err.Error()) // Handle errors reading the config file
return Constants{}, err
}
if err = viper.ReadInConfig(); err != nil {
log.Panicf("Error reading config file, %s", err)
}
var constants Constants
err = viper.Unmarshal(&constants)
return constants, err
}
Also attaching the main function go file where I am calling the DB function for connecting the service to DB-
package main
import (
"bufio"
"encoding/base64"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"strings"
"time"
gokitlog "github.com/go-kit/kit/log"
"github.com/go-kit/kit/log/level"
"github.com/gorilla/mux"
"github.com/jinzhu/gorm"
_ "github.com/lib/pq"
)
func runServer(config *common.Constants) {
var logger gokitlog.Logger
logger = gokitlog.NewLogfmtLogger(gokitlog.NewSyncWriter(os.Stderr))
logger = gokitlog.With(logger, "ts", gokitlog.DefaultTimestampUTC)
dbService := db2.NewService()
var db *gorm.DB
dbService.InitDatabaseConnection(&db)
err := dbService.Migrate(db)
if err != nil {
fmt.Println("Migrations scripts did not run :", err.Error())
if err.Error() != "no change" {
fmt.Println("failed to run migrations hence exiting", err.Error())
os.Exit(1)
}
}
level.Info(logger).Log("Starting Darwin ==========>")
repo, err := allocation.NewUserAllocationRepository(db, logger)
if err != nil {
level.Error(logger).Log("exit", err)
os.Exit(3)
}
userAllocationService := allocation.NewUserAllocationService(repo, logger)
endpoints := allocation.MakeEndpoints(userAllocationService)
router := mux.NewRouter()
sh := http.StripPrefix("/swaggerui/", http.FileServer(http.Dir("../resources/swaggerui/")))
router.PathPrefix("/swaggerui/").Handler(sh)
httpLogger := gokitlog.With(logger, "component", "http")
subRouter := router.PathPrefix("/api/v1").Subrouter()
subRouter.NotFoundHandler = http.HandlerFunc(notFound)
subRouter.Use(checkBasicAuth(config))
subRouter.HandleFunc("/healthCheck", healthCheck).Methods("GET")
allocation.MakeHandler(subRouter, httpLogger, endpoints)
http.Handle("/", subRouter)
log.Print("Running server on port 8080")
f, err := os.OpenFile("../performance.log", os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666)
if err != nil {
log.Printf("error opening file: %v", err)
log.Fatal(http.ListenAndServe(":8080", router))
} else {
by := bufio.NewWriter(f)
defer f.Close()
log.Fatal(http.ListenAndServe(":8080", Logger(by, router)))
}
}
func healthCheck(w http.ResponseWriter, r *http.Request) {
resp := map[string]string{
"Status": "Success",
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(resp)
}
func notFound(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json;")
w.WriteHeader(http.StatusNotFound)
json.NewEncoder(w).Encode(map[string]string{"error": "api not found"})
}
func authHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json;")
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]string{"error": "Not authorized"})
}
func checkBasicAuth(config *common.Constants) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.RequestURI() != "/api/v1/healthCheck" {
w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`)
w.Header().Set("Content-Type", "application/json;")
s := strings.SplitN(r.Header.Get("Authorization"), " ", 2)
if len(s) != 2 {
authHandler(w, r)
return
}
b, err := base64.StdEncoding.DecodeString(s[1])
if err != nil {
authHandler(w, r)
return
}
pair := strings.SplitN(string(b), ":", 2)
if len(pair) != 2 {
authHandler(w, r)
return
}
if pair[0] != config.AuthUserName || pair[1] != config.AuthPassword {
authHandler(w, r)
return
}
}
next.ServeHTTP(w, r)
})
}
}
func Logger(out *bufio.Writer, h http.Handler) http.Handler {
logger := log.New(out, "", 0)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
o := &responseObserver{ResponseWriter: w, status: http.StatusOK}
start := time.Now()
h.ServeHTTP(o, r)
endTime := time.Now()
if !strings.Contains(r.URL.String(), "/health") && strings.Contains(r.URL.String(), "/api") {
logger.Printf("Method: %s Path: %s Status: %d ExecutionTime: %d ms", r.Method, r.URL, o.status, endTime.Sub(start).Milliseconds())
out.Flush()
}
})
}
type responseObserver struct {
http.ResponseWriter
status int
}
func (r *responseObserver) WriteHeader(status int) {
r.status = status
r.ResponseWriter.WriteHeader(status)
}
I have found a lot of questions regarding gorm mocking related to the old V1 package: github.com/jinzhu/gorm. with the usage of github.com/DATA-DOG/go-sqlmock.
I didn't find much with the v2.
My simple question is:
Suppose I have this storage package code:
...
type Storage struct {
GormDB *gorm.DB
SqlDB *sql.DB
mutex sync.Mutex
ReadTimeout int
WriteTimeout int
}
func (ps *Storage) Open(settings *Settings) error {
if err := settings.Validate(); err != nil {
return err
}
ps.mutex.Lock()
defer ps.mutex.Unlock()
if ps.GormDB != nil {
return nil
}
gormDB, err := gorm.Open(postgres.New(postgres.Config{
DSN: settings.GetDSN(),
}), &gorm.Config{
SkipDefaultTransaction: true,
})
if err != nil {
return fmt.Errorf("%s: %v", DBConnectError, err)
}
ps.GormDB = gormDB
sqlDB, err := ps.GormDB.DB()
if err != nil {
return fmt.Errorf("%s: %v", DBRetrievalError, err)
}
ps.SqlDB = sqlDB
ps.SqlDB.SetMaxIdleConns(settings.MaxIdleConnections)
ps.SqlDB.SetMaxOpenConns(settings.MaxOpenConnections)
ps.ReadTimeout = settings.ReadTimeout
ps.WriteTimeout = settings.WriteTimeout
return nil
}
How can I unit-test this function with a simple check that gorm.Open received the expected config?
I don't see any other way than passing the ORM interface to this method... It would be a tough solution to write a gorm interface and mock it myself...
Can anyone please provide a simple example of mocking such a function?
P.S.
I don't want to run the docker with Postgres for this test. It is a simple unit test, not integration.
EDIT:
Suppose I just want to mock the connection to make gorm.Open to not return an error. How can I do it?
sqlmock.NewWithDSN(settings.GetDSN()) does not help
Answering my own question here.
Thanks to #flimzy for pointing me in the right direction.
To be able to test the storage open need to modify the function:
func (ps *Storage) Open(settings *Settings, postgresConfig *postgres.Config) error {
if err := settings.Validate(); err != nil {
return err
}
ps.mutex.Lock()
defer ps.mutex.Unlock()
if ps.GormDB != nil {
return nil
}
gormDB, err := gorm.Open(postgres.New(*postgresConfig), &gorm.Config{
SkipDefaultTransaction: true,
})
if err != nil {
return fmt.Errorf("%s: %v", DBConnectError, err)
}
ps.GormDB = gormDB
sqlDB, err := ps.GormDB.DB()
if err != nil {
return fmt.Errorf("%s: %v", DBRetrievalError, err)
}
ps.SqlDB = sqlDB
ps.SqlDB.SetMaxIdleConns(settings.MaxIdleConnections)
ps.SqlDB.SetMaxOpenConns(settings.MaxOpenConnections)
ps.ReadTimeout = settings.ReadTimeout
ps.WriteTimeout = settings.WriteTimeout
return nil
}
To mock gorm connection we just pass go-sqlmock connection in the postgres config of the form postgres driver:
package postgres
import (
"github.com/DATA-DOG/go-sqlmock"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
)
var _ = Describe("Postgres Storage", func() {
...
Describe("Open", func() {
Context("when settings are valid", func() {
It("should open a DB connection and initialize storage", func() {
sqlDB, sqlMock, _ := sqlmock.New()
sqlMock.ExpectPing()
err := storage.Open(settings, postgres.Config{Conn: sqlDB})
dbStats := storage.sqlDB.Stats()
Expect(err).ShouldNot(HaveOccurred())
Expect(dbStats.MaxOpenConnections).To(Equal(settings.MaxOpenConnections))
Expect(dbStats.OpenConnections).To(Equal(1))
Expect(storage.ReadTimeout).To(Equal(settings.ReadTimeout))
Expect(storage.WriteTimeout).To(Equal(settings.WriteTimeout))
Expect(sqlMock.ExpectationsWereMet()).To(BeNil())
})
})
})
})
I'm currently trying to send a POST request to an external API from a GCP Cloud Function. I've tested the function extensively locally and it fulfills the request every time and also works from Postman, but when I run the exact same code from within a cloud function, it returns a 500 from the external API every single time.
I'm genuinely at a loss as to why when sending the POST request from within the cloud function it fails every single time.
Does GCP add any headers that might interfere with an external API call or is there a configuration option within the cloud function settings that needs to be configured to allow an external POST request?
I've attempted to implement an http retry mechanism, but that did not work either.
Again, locally and from Postman, the exact same code is successful every time I run it.
Here is the code I use to generate and send the request:
package email
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"log"
"net/http"
"github.com/hashicorp/go-retryablehttp"
)
var FailedRequestErr = errors.New("failed request to moosend")
const (
successCode = 0
moosendHost = "api.moosend.com/v3"
dailyNewsletterMailingListID = "2e461f4c-99d1-4a8e-80ea-168b20bdaf5f"
mainEmail = "jason#functionalbits.io"
campaignNameBase = "Functional Bits Newsletter - Issue"
campaignSubjectBase = "Functional Bits Issue"
)
type CreatingADraftCampaignRequest struct {
Name string `json:"Name"`
Subject string `json:"Subject"`
SenderEmail string `json:"SenderEmail"`
ReplyToEmail string `json:"ReplyToEmail"`
IsAB string `json:"IsAB"`
ConfirmationToEmail string `json:"ConfirmationToEmail,omitempty"`
WebLocation string `json:"WebLocation,omitempty"`
MailingLists []MailingLists `json:"MailingLists,omitempty"`
SegmentID string `json:"SegmentID,omitempty"`
ABCampaignType string `json:"ABCampaignType,omitempty"`
TrackInGoogleAnalytics string `json:"TrackInGoogleAnalytics,omitempty"`
DontTrackLinkClicks string `json:"DontTrackLinkClicks,omitempty"`
SubjectB string `json:"SubjectB,omitempty"`
WebLocationB string `json:"WebLocationB,omitempty"`
SenderEmailB string `json:"SenderEmailB,omitempty"`
HoursToTest string `json:"HoursToTest,omitempty"`
ListPercentage string `json:"ListPercentage,omitempty"`
ABWinnerSelectionType string `json:"ABWinnerSelectionType,omitempty"`
}
type MailingLists struct {
MailingListID string `json:"MailingListId"`
SegmentID float64 `json:"SegmentId,omitempty"`
}
type CampaignResponse struct {
Code int32 `json:"Code"`
Err interface{} `json:"Error"`
Context interface{} `json:"Context"`
}
type MoosendAPI struct {
apiKey string
client *http.Client
}
func NewMoosendAPI(apiKey string) *MoosendAPI {
retryClient := retryablehttp.NewClient()
retryClient.RetryMax = 5
standardClient := retryClient.StandardClient()
return &MoosendAPI{
apiKey: apiKey,
client: standardClient,
}
}
func (m *MoosendAPI) CreateDraftCampaign(issueNumber string, webLocation string) (*CampaignResponse, error) {
campaign := CreatingADraftCampaignRequest{
Name: fmt.Sprintf("%s %s", campaignNameBase, issueNumber),
Subject: fmt.Sprintf("%s %s", campaignSubjectBase, issueNumber),
IsAB: "false",
WebLocation: webLocation,
MailingLists: []MailingLists{{MailingListID: dailyNewsletterMailingListID}},
SenderEmail: mainEmail,
ReplyToEmail: mainEmail,
ConfirmationToEmail: mainEmail,
TrackInGoogleAnalytics: "true",
}
body, err := json.Marshal(&campaign)
if err != nil {
log.Println("error marshalling campaign request")
return nil, err
}
fullURL := fmt.Sprintf("https://%s/campaigns/create.json?apikey=%s", moosendHost, m.apiKey)
req, err := http.NewRequest(http.MethodPost, fullURL, bytes.NewBuffer(body))
if err != nil {
log.Println("request error")
return nil, err
}
req.Header.Add("Content-Type", "application/json")
req.Header.Add("Accept", "application/json")
log.Printf("request: %+v", req)
resp, err := m.client.Do(req)
if resp.StatusCode != http.StatusOK {
return nil, FailedRequestErr
}
if err != nil {
log.Println("error sending request")
return nil, err
}
log.Printf("response: %+v", resp)
defer resp.Body.Close()
respBody, err := ioutil.ReadAll(resp.Body)
if err != nil {
log.Println("error reading response body")
return nil, err
}
var draftResponse CampaignResponse
if err := json.Unmarshal(respBody, &draftResponse); err != nil {
log.Println("error unmarshalling response")
log.Printf("%+v", draftResponse)
return nil, err
}
return &draftResponse, nil
}
func (m *MoosendAPI) SendCampaign(campaignID string) error {
fullURL := fmt.Sprintf("https://%s/campaigns/%s/send.json?apikey=%s", moosendHost, campaignID, m.apiKey)
req, err := http.NewRequest(http.MethodPost, fullURL, nil)
if err != nil {
log.Println("error creating request")
return err
}
req.Header.Add("Content-Type", "application/json")
req.Header.Add("Accept", "application/json")
resp, err := m.client.Do(req)
if err != nil {
log.Println("error sending request")
return err
}
defer resp.Body.Close()
respBody, err := ioutil.ReadAll(resp.Body)
if err != nil {
log.Println("error reading response body")
return err
}
var sendResponse CampaignResponse
if err := json.Unmarshal(respBody, &sendResponse); err != nil {
log.Println("error unmarshalling response")
log.Printf("%+v", sendResponse)
return err
}
return nil
}
Then how it's run in the main function code:
package function
import (
"context"
"encoding/json"
"errors"
"log"
"os"
"github.com/Functional-Bits/emailer-service/internal/email"
"github.com/Functional-Bits/emailer-service/internal/publish"
)
func CampaignGenerator(ctx context.Context, m publish.PubSubMessage) error {
moosendAPIKey, ok := os.LookupEnv("MOOSEND_API_KEY")
if !ok {
log.Println("missing moosendAPIKey")
}
mAPI := email.NewMoosendAPI(moosendAPIKey)
var msg publish.IncomingMessage
if err := json.Unmarshal(m.Data, &msg); err != nil {
log.Println(err)
return err
}
log.Printf("received message: %+v", msg)
log.Printf("generating draft campaign for issue %s", msg.IssueNumber)
draftResponse, err := mAPI.CreateDraftCampaign(msg.IssueNumber, msg.FileURL)
if err != nil {
log.Println(err)
return err
}
log.Printf("draft response: %+v", draftResponse)
campaignID, ok := draftResponse.Context.(string)
if !ok {
log.Printf("response didn't contain an ID: %+v", draftResponse)
return errors.New("no campaign generated")
}
log.Printf("sending campgain %s", campaignID)
if err := mAPI.SendCampaign(campaignID); err != nil {
log.Println(err)
return err
}
log.Printf("campaign successfully sent for issue number %s", msg.IssueNumber)
return nil
}
When this code is run locally, It correctly makes the 2 calls and sends an email campaign. When run from the cloud function I get a 500 internal server error with no additional information as to why. Link to API docs.
I get the following response from the external API (from my cloud function logs)
response: &{
Status:500 Internal Server Error
StatusCode:500
Proto:HTTP/1.1
ProtoMajor:1
ProtoMinor:1
Header:map[Access-Control-Allow-Headers:[Content-Type, Accept, Cache-Control, X-Requested-With]
Access-Control-Allow-Methods:[GET, POST, OPTIONS, DELETE, PUT]
Access-Control-Allow-Origin:[*]
Cache-Control:[private]
Content-Length:[12750]
Content-Type:[text/html; charset=utf-8]
Date:[Sun, 12 Dec 2021 07:00:09 GMT]
Server:[Microsoft-IIS/10.0]
X-Aspnet-Version:[4.0.30319]
X-Powered-By:[ASP.NET]
X-Robots-Tag:[noindex, nofollow]
X-Server-Id:[1]]
Body:0xc0003f04c0
ContentLength:12750
TransferEncoding:[]
Close:false
Uncompressed:false
Trailer:map[]
Request:0xc000160b00
TLS:0xc000500630
}
The response causes an unmarshal error because no campaign ID is returned.
I am building a simple function that calls an API that returns a Post using GraphQL (https://github.com/machinebox/graphql). I wrapped the logic in a service that looks like this:
type Client struct {
gcl graphqlClient
}
type graphqlClient interface {
Run(ctx context.Context, req *graphql.Request, resp interface{}) error
}
func (c *Client) GetPost(id string) (*Post, error) {
req := graphql.NewRequest(`
query($id: String!) {
getPost(id: $id) {
id
title
}
}
`)
req.Var("id", id)
var resp getPostResponse
if err := c.gcl.Run(ctx, req, &resp); err != nil {
return nil, err
}
return resp.Post, nil
}
Now I'd like to add test tables for the GetPost function with a fail case when id is set to empty string which causes an error in the downstream call c.gcl.Run.
What I am struggling with is the way the gcl client can be mocked and forced to return the error (when no real API call happens).
My test so far:
package apiClient
import (
"context"
"errors"
"github.com/aws/aws-sdk-go/aws"
"github.com/google/go-cmp/cmp"
"github.com/machinebox/graphql"
"testing"
)
type graphqlClientMock struct {
graphqlClient
HasError bool
Response interface{}
}
func (g graphqlClientMock) Run(_ context.Context, _ *graphql.Request, response interface{}) error {
if g.HasError {
return errors.New("")
}
response = g.Response
return nil
}
func newTestClient(hasError bool, response interface{}) *Client {
return &Client{
gcl: graphqlClientMock{
HasError: hasError,
Response: response,
},
}
}
func TestClient_GetPost(t *testing.T) {
tt := []struct{
name string
id string
post *Post
hasError bool
response getPostResponse
}{
{
name: "empty id",
id: "",
post: nil,
hasError: true,
},
{
name: "existing post",
id: "123",
post: &Post{id: aws.String("123")},
response: getPostResponse{
Post: &Post{id: aws.String("123")},
},
},
}
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
client := newTestClient(tc.hasError, tc.response)
post, err := client.GetPost(tc.id)
if err != nil {
if tc.hasError == false {
t.Error("unexpected error")
}
} else {
if tc.hasError == true {
t.Error("expected error")
}
if cmp.Equal(post, &tc.post) == false {
t.Errorf("Response data do not match: %s", cmp.Diff(post, tc.post))
}
}
})
}
}
I am not sure if passing the response to the mock like this is the right way to do it. Also, I'm struggling to set the right value to the response, since an interface{} type is passed and I don't know how to convert it to the getPostResponse and set the value to Post there.
Your test cases should not go beyond the implementation. I'm specifically referring to the empty-vs-nonempty input or any kind of input really.
Let's take a look at the code you want to test:
func (c *Client) GetPost(id string) (*Post, error) {
req := graphql.NewRequest(`
query($id: String!) {
getPost(id: $id) {
id
title
}
}
`)
req.Var("id", id)
var resp getPostResponse
if err := c.gcl.Run(ctx, req, &resp); err != nil {
return nil, err
}
return resp.Post, nil
}
Nothing in the implementation above is doing anything based on the id parameter value and therefore nothing in your tests for this piece of code should really care about what input is passed in, if it is irrelevant to the implementation it should also be irrelevant to the tests.
Your GetPost has basically two code branches that are taken based on a single factor, i.e. the "nilness" of the returned err variable. This means that as far as your implementation is concerned there are only two possible outcomes, based on what err value Run returns, and therefore there should only be two test cases, a 3rd or 4th test case would be just a variation, if not an outright copy, of the first two.
Your test client is also doing some unnecessary stuff, the main one being its name, i.e. what you have there is not a mock so calling it that is not helpful. Mocks usually do a lot more than just return predefined values, they ensure that methods are called, in the expected order and with the expected arguments, etc. And actually you don't need a mock here at all so it's a good thing it isn't one.
With that in mind, here's what I would suggest you do with your test client.
type testGraphqlClient struct {
resp interface{} // non-pointer value of the desired response, or nil
err error // the error to be returned by Run, or nil
}
func (g testGraphqlClient) Run(_ context.Context, _ *graphql.Request, resp interface{}) error {
if g.err != nil {
return g.err
}
if g.resp != nil {
// use reflection to set the passed in response value
// (i haven't tested this so there may be a bug or two)
reflect.ValueOf(resp).Elem().Set(reflect.ValueOf(g.resp))
}
return nil
}
... and here are the necessary test cases, all two of them:
func TestClient_GetPost(t *testing.T) {
tests := []struct {
name string
post *Post
err error
client testGraphqlClient
}{{
name: "return error from client",
err: errors.New("bad input"),
client: testGraphqlClient{err: errors.New("bad input")},
}, {
name: "return post from client",
post: &Post{id: aws.String("123")},
client: testGraphqlClient{resp: getPostResponse{Post: &Post{id: aws.String("123")}}},
}}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client := Client{gql: tt.client}
post, err := client.GetPost("whatever")
if !cmp.Equal(err, tt.err) {
t.Errorf("got error=%v want error=%v", err, tt.err)
}
if !cmp.Equal(post, tt.post) {
t.Errorf("got post=%v want post=%v", post, tt.post)
}
})
}
}
... there's a bit of repetition going on here, the need to spell out the post and err twice but that's a small price to pay when compared to a more sophisticated/complicated test setup that would populate the test client from the test case's expected output fields.
Addendum:
If you were to update GetPost in such a way that it checks for the empty id and returns an error before it sends a request to graphql then your initial setup would make much more sense:
func (c *Client) GetPost(id string) (*Post, error) {
if id == "" {
return nil, errors.New("empty id")
}
req := graphql.NewRequest(`
query($id: String!) {
getPost(id: $id) {
id
title
}
}
`)
req.Var("id", id)
var resp getPostResponse
if err := c.gcl.Run(ctx, req, &resp); err != nil {
return nil, err
}
return resp.Post, nil
}
... and updating the test cases accordingly:
func TestClient_GetPost(t *testing.T) {
tests := []struct {
name string
id string
post *Post
err error
client testGraphqlClient
}{{
name: "return empty id error",
id: "",
err: errors.New("empty id"),
client: testGraphqlClient{},
}, {
name: "return error from client",
id: "nonemptyid",
err: errors.New("bad input"),
client: testGraphqlClient{err: errors.New("bad input")},
}, {
name: "return post from client",
id: "nonemptyid",
post: &Post{id: aws.String("123")},
client: testGraphqlClient{resp: getPostResponse{Post: &Post{id: aws.String("123")}}},
}}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client := Client{gql: tt.client}
post, err := client.GetPost(tt.id)
if !cmp.Equal(err, tt.err) {
t.Errorf("got error=%v want error=%v", err, tt.err)
}
if !cmp.Equal(post, tt.post) {
t.Errorf("got post=%v want post=%v", post, tt.post)
}
})
}
}
I don't know how to test the http response given in the code below.
func getVolDetails(volName string, obj interface{}) error {
addr := os.Getenv("MAPI_ADDR")
if addr == "" {
err := errors.New("MAPI_ADDR environment variable not set")
fmt.Println(err)
return err
}
url := addr + "/path/to/somepage/" + volName
client := &http.Client{
Timeout: timeout,
}
resp, err := client.Get(url)
if resp != nil {
if resp.StatusCode == 500 {
fmt.Printf("VSM %s not found\n", volName)
return err
} else if resp.StatusCode == 503 {
fmt.Println("server not reachable")
return err
}
} else {
fmt.Println("server not reachable")
return err
}
if err != nil {
fmt.Println(err)
return err
}
defer resp.Body.Close()
return json.NewDecoder(resp.Body).Decode(obj)
}
With the help of some references i wrote unit test for this which is given below
func TestGetVolDetails(t *testing.T) {
var (
volume v1.Volume
server *httptest.Server
)
tests := map[string]struct {
volumeName string
err error
}{
"TestOne": {"vol", nil},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
response := `{"metadata":{"annotations":{"vsm.openebs.io/targetportals":"10.98.65.136:3260","vsm.openebs.io/cluster-i ps":"10.98.65.136","openebs.io/jiva-iqn":"iqn.2016-09.com.openebs.jiva:vol","deployment.kubernetes.io/revision":"1","openebs.io/storage-pool" :"default","vsm.openebs.io/replica-count":"1","openebs.io/jiva-controller-status":"Running","openebs.io/volume-monitor":"false","openebs.io/r eplica-container-status":"Running","openebs.io/jiva-controller-cluster-ip":"10.98.65.136","openebs.io/jiva-replica-status":"Running","vsm.ope nebs.io/iqn":"iqn.2016-09.com.openebs.jiva:vol","openebs.io/capacity":"2G","openebs.io/jiva-controller-ips":"10.36.0.6","openebs.io/jiva-repl ica-ips":"10.36.0.7","vsm.openebs.io/replica-status":"Running","vsm.openebs.io/controller-status":"Running","openebs.io/controller-container- status":"Running","vsm.openebs.io/replica-ips":"10.36.0.7","openebs.io/jiva-target-portal":"10.98.65.136:3260","openebs.io/volume-type":"jiva ","openebs.io/jiva-replica-count":"1","vsm.openebs.io/volume-size":"2G","vsm.openebs.io/controller-ips":"10.36.0.6"},"creationTimestamp":null ,"labels":{},"name":"vol"},"status":{"Message":"","Phase":"Running","Reason":""}}`
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, response)
}))
os.Setenv("MAPI_ADDR", "http://"+server.URL)
if got := GetVolDetails(tt.volumeName, &volume); got != tt.err {
t.Fatalf("GetVolDetails(%v) => got %v, want %v ", tt.volumeName, got, tt.err)
}
defer server.Close()
})
}
}
Where response is the response i'm getting from the server. This gives me always different errors.
got invalid character '<' looking for beginning of value, want <nil>
got Get http://www.HugeDomains.com: net/http: request canceled (Client.Timeout exceeded while awaiting headers), want <nil>
What am I doing wrong?
Edit:
Updated the code with SOME_ADDR to MAPI_ADDR which was done while posting question. Please don't be confused with that, problem remains as it is.
You are getting a timeout but you are not specifying what timeout is set to. I suspect that this is not a time.Duration object and that is causing your timeout. There are a few other issues as well. To get this to work I did:
Change the function being called in the test to getVolDetails to match the code (not the lower case g)
Set the Timeout when creating the client to Timeout: time.Second * 10
Remove the "http://"+ from the os.Setenv("MAPI_ADDR", "http://"+server.URL) line
Corrected code is:
var timeout time.Duration = time.Second * 1000
func getVolDetails(volName string, obj interface{}) error {
addr := os.Getenv("MAPI_ADDR")
if addr == "" {
err := errors.New("MAPI_ADDR environment variable not set")
fmt.Println(err)
return err
}
url := addr + "/path/to/somepage/" + volName
client := &http.Client{
Timeout: timeout,
}
resp, err := client.Get(url)
if resp != nil {
if resp.StatusCode == 500 {
fmt.Printf("VSM %s not found\n", volName)
return err
} else if resp.StatusCode == 503 {
fmt.Println("server not reachable")
return err
}
} else {
fmt.Println("server not reachable")
return err
}
if err != nil {
fmt.Println(err)
return err
}
defer resp.Body.Close()
return json.NewDecoder(resp.Body).Decode(obj)
}
and test:
func TestGetVolDetails(t *testing.T) {
var (
volume v1.Volume
server *httptest.Server
)
tests := map[string]struct {
volumeName string
err error
}{
"TestOne": {"vol", nil},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
response := `{"metadata":{"annotations":{"vsm.openebs.io/targetportals":"10.98.65.136:3260","vsm.openebs.io/cluster-i ps":"10.98.65.136","openebs.io/jiva-iqn":"iqn.2016-09.com.openebs.jiva:vol","deployment.kubernetes.io/revision":"1","openebs.io/storage-pool" :"default","vsm.openebs.io/replica-count":"1","openebs.io/jiva-controller-status":"Running","openebs.io/volume-monitor":"false","openebs.io/r eplica-container-status":"Running","openebs.io/jiva-controller-cluster-ip":"10.98.65.136","openebs.io/jiva-replica-status":"Running","vsm.ope nebs.io/iqn":"iqn.2016-09.com.openebs.jiva:vol","openebs.io/capacity":"2G","openebs.io/jiva-controller-ips":"10.36.0.6","openebs.io/jiva-repl ica-ips":"10.36.0.7","vsm.openebs.io/replica-status":"Running","vsm.openebs.io/controller-status":"Running","openebs.io/controller-container- status":"Running","vsm.openebs.io/replica-ips":"10.36.0.7","openebs.io/jiva-target-portal":"10.98.65.136:3260","openebs.io/volume-type":"jiva ","openebs.io/jiva-replica-count":"1","vsm.openebs.io/volume-size":"2G","vsm.openebs.io/controller-ips":"10.36.0.6"},"creationTimestamp":null ,"labels":{},"name":"vol"},"status":{"Message":"","Phase":"Running","Reason":""}}`
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, response)
}))
os.Setenv("MAPI_ADDR", server.URL)
if got := getVolDetails(tt.volumeName, &volume); got != tt.err {
t.Fatalf("GetVolDetails(%v) => got %v, want %v ", tt.volumeName, got, tt.err)
}
defer server.Close()
})
}
}