internal/runtimeconfig: add tests. Allow user to set any minimum value for wait time > 0. Change-Id: I61592707f840029c6f401e383d66fd9608b993f5 Reviewed-on: https://go-review.googlesource.com/85655 Reviewed-by: Ross Light <light@google.com>
diff --git a/internal/runtimeconfig/example_test.go b/internal/runtimeconfig/example_test.go new file mode 100644 index 0000000..dcf0517 --- /dev/null +++ b/internal/runtimeconfig/example_test.go
@@ -0,0 +1,45 @@ +package runtimeconfig_test + +import ( + "context" + "log" + + "github.com/golang/gddo/internal/runtimeconfig" +) + +func Example() { + // Create a Client object. + ctx := context.Background() + client, err := runtimeconfig.NewClient(ctx) + if err != nil { + log.Fatal(err) + } + defer client.Close() + + // Create a Watcher object. + w, err := client.NewWatcher(ctx, "project", "config-name", "food", nil) + // Use retrieved Variable and apply to configurations accordingly. + log.Printf("value: %s\n", string(w.Variable().Value)) + + // Optionally, get a Context with cancel func to stop the Watch call. + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + // Have a separate goroutine that waits for changes. + go func() { + for { + select { + case <-ctx.Done(): + // Cancelled or timed out. + return + default: + if err := w.Watch(ctx); err != nil { + // Log or handle other errors + continue + } + // Use updated variable accordingly. + log.Printf("value: %s\n", string(w.Variable().Value)) + } + } + }() +}
diff --git a/internal/runtimeconfig/runtimeconfig.go b/internal/runtimeconfig/runtimeconfig.go index a29d1f6..9cc8c61 100644 --- a/internal/runtimeconfig/runtimeconfig.go +++ b/internal/runtimeconfig/runtimeconfig.go
@@ -23,8 +23,12 @@ "google.golang.org/grpc/status" ) -// endpoint is the address of the GCP Runtime Configurator API. -const endPoint = "runtimeconfig.googleapis.com:443" +const ( + // endpoint is the address of the GCP Runtime Configurator API. + endPoint = "runtimeconfig.googleapis.com:443" + // defaultWaitTimeout is the default value for WatchOptions.WaitTime if not set. + defaultWaitTimeout = 10 * time.Minute +) // List of authentication scopes required for using the Runtime Configurator API. var authScopes = []string{ @@ -32,11 +36,6 @@ "https://www.googleapis.com/auth/cloudruntimeconfig", } -const ( - defaultWaitTimeout = 10 * time.Minute - minWaitTimeout = 10 * time.Second -) - // Client is a RuntimeConfigManager client. It wraps the gRPC client stub and currently exposes // only a few APIs primarily for fetching and watching over configuration variables. type Client struct { @@ -82,8 +81,8 @@ switch { case waitTime == 0: waitTime = defaultWaitTimeout - case waitTime < minWaitTimeout: - waitTime = minWaitTimeout + case waitTime < 0: + return nil, fmt.Errorf("cannot have negative WaitTime option value: %v", waitTime) } // Make sure update time is valid before copying. @@ -105,10 +104,10 @@ type WatchOptions struct { // WaitTime controls the frequency of making RPC and checking for updates by the Watch method. // A Watcher keeps track of the last time it made an RPC, when Watch is called, it waits for - // configured WaitTime from the last RPC before making another RPC. + // configured WaitTime from the last RPC before making another RPC. The smaller the value, the + // higher the frequency of making RPCs, which also means faster rate of hitting the API quota. // - // If this option is not set, it defaults to defaultWaitTimeout. If option is set to a value - // smaller than minWaitTimeout, it uses minWaitTimeout value instead. + // If this option is not set or set to 0, it uses defaultWaitTimeout value. WaitTime time.Duration }
diff --git a/internal/runtimeconfig/runtimeconfig_test.go b/internal/runtimeconfig/runtimeconfig_test.go index ee8178e..35fb3e8 100644 --- a/internal/runtimeconfig/runtimeconfig_test.go +++ b/internal/runtimeconfig/runtimeconfig_test.go
@@ -2,42 +2,267 @@ import ( "context" - "log" + "fmt" + "net" + "strings" + "testing" + "time" + + tspb "github.com/golang/protobuf/ptypes/timestamp" + "github.com/google/go-cmp/cmp" + pb "google.golang.org/genproto/googleapis/cloud/runtimeconfig/v1beta1" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" ) -func Example() { - // Create a Client object. - ctx := context.Background() - client, err := NewClient(ctx) - if err != nil { - log.Fatal(err) +// Set wait timeout used for tests. +var watchOpt = &WatchOptions{ + WaitTime: 500 * time.Millisecond, +} + +// fakeServer partially implements RuntimeConfigManagerServer for Client to connect to. Prefill +// responses field with the ordered list of responses to GetVariable calls. +type fakeServer struct { + pb.RuntimeConfigManagerServer + responses []response + index int +} + +type response struct { + vrbl *pb.Variable + err error +} + +func (s *fakeServer) GetVariable(context.Context, *pb.GetVariableRequest) (*pb.Variable, error) { + if len(s.responses) == 0 { + return nil, fmt.Errorf("fakeClient missing responses") } - defer client.Close() + resp := s.responses[s.index] + // Adjust index to next response for next call till it gets to last one, then keep using the + // last one. + if s.index < len(s.responses)-1 { + s.index++ + } + return resp.vrbl, resp.err +} - // Create a Watcher object. - w, err := client.NewWatcher(ctx, "project", "config-name", "food", nil) - // Use retrieved Variable and apply to configurations accordingly. - log.Printf("value: %s\n", string(w.Variable().Value)) +func setUp(t *testing.T, fs *fakeServer) (*Client, func()) { + // TODO: Replace logic to use a port picker. + const address = "localhost:8888" + // Set up gRPC server. + lis, err := net.Listen("tcp", address) + if err != nil { + t.Fatalf("tcp listen on %s failed: %v", address, err) + } + s := grpc.NewServer() + pb.RegisterRuntimeConfigManagerServer(s, fs) + // Run gRPC server on a background goroutine. + go s.Serve(lis) - // Optionally, get a Context with cancel func to stop the Watch call. - ctx, cancel := context.WithCancel(ctx) - defer cancel() - - // Have a separate goroutine that waits for changes. - go func() { - for { - select { - case <-ctx.Done(): - // Cancelled or timed out. - return - default: - if err := w.Watch(ctx); err != nil { - // Log or handle other errors - continue - } - // Use updated variable accordingly. - log.Printf("value: %s\n", string(w.Variable().Value)) - } + // Set up client. + conn, err := grpc.Dial(address, grpc.WithInsecure()) + if err != nil { + t.Fatalf("did not connect: %v", err) + } + return &Client{ + conn: conn, + client: pb.NewRuntimeConfigManagerClient(conn), + }, func() { + conn.Close() + s.Stop() + time.Sleep(time.Second * 1) } +} + +func pbToVariable(vpb *pb.Variable) (*Variable, error) { + vrbl := &Variable{} + tm, err := parseUpdateTime(vpb) + if err != nil { + return nil, err + } + copyFromProto(vpb, vrbl, tm) + return vrbl, nil +} + +var ( + startTime = time.Now().Unix() + vrbl1 = &pb.Variable{ + Name: "greetings", + Contents: &pb.Variable_Text{"hello"}, + UpdateTime: &tspb.Timestamp{Seconds: startTime}, + } + vrbl2 = &pb.Variable{ + Name: "greetings", + Contents: &pb.Variable_Text{"world"}, + UpdateTime: &tspb.Timestamp{Seconds: startTime + 100}, + } +) + +func TestNewWatcher(t *testing.T) { + client, cleanUp := setUp(t, &fakeServer{ + responses: []response{ + {vrbl: vrbl1}, + }, + }) + defer cleanUp() + + ctx := context.Background() + w, err := client.NewWatcher(ctx, "projectID", "config", "greetings", watchOpt) + if err != nil { + t.Fatalf("Client.NewWatcher() returned error: %v", err) + } + + got := w.Variable() + want, err := pbToVariable(vrbl1) + if err != nil { + t.Fatal(err) + } + if diff := cmp.Diff(&got, want); diff != "" { + t.Errorf("Watcher.Variable(): %s", diff) + } +} + +func TestWatchUpdatesVariable(t *testing.T) { + client, cleanUp := setUp(t, &fakeServer{ + responses: []response{ + {vrbl: vrbl1}, + {vrbl: vrbl2}, + }, + }) + defer cleanUp() + + ctx := context.Background() + w, err := client.NewWatcher(ctx, "projectID", "config", "greetings", watchOpt) + if err != nil { + t.Fatalf("Client.NewWatcher() returned error: %v", err) + } + + if err := w.Watch(ctx); err != nil { + t.Fatalf("Watcher.Watch() returned error: %v", err) + } + got := w.Variable() + want, err := pbToVariable(vrbl2) + if err != nil { + t.Fatal(err) + } + if diff := cmp.Diff(&got, want); diff != "" { + t.Errorf("Watcher.Variable(): %s", diff) + } +} + +func TestWatchVariableDeletedAndReset(t *testing.T) { + client, cleanUp := setUp(t, &fakeServer{ + responses: []response{ + {vrbl: vrbl1}, + {err: status.Error(codes.NotFound, "deleted")}, + {vrbl: vrbl2}, + }, + }) + defer cleanUp() + + ctx := context.Background() + w, err := client.NewWatcher(ctx, "projectID", "config", "greetings", watchOpt) + if err != nil { + t.Fatalf("Client.NewWatcher() returned error: %v", err) + } + + // Expect deleted error. + if err := w.Watch(ctx); err == nil { + t.Fatalf("Watcher.Watch() returned nil, want error") + } else { + if !IsDeleted(err) { + t.Fatalf("Watcher.Watch() returned error %v, want errDeleted", err) + } + } + + // Variable Name and Value should be the same, IsDeleted and UpdateTime should be updated. + got := w.Variable() + prev, err := pbToVariable(vrbl1) + if err != nil { + t.Fatal(err) + } + if got.Name != prev.Name { + t.Errorf("Variable.Name got %v, want %v", got.Name, prev.Name) + } + if diff := cmp.Diff(got.Value, prev.Value); diff != "" { + t.Errorf("Variable.Value: %s", diff) + } + if !got.IsDeleted { + t.Errorf("Variable.IsDeleted got %v, want true", got.IsDeleted) + } + if !got.UpdateTime.After(prev.UpdateTime) { + t.Errorf("Variable.UpdateTime is less than or equal to previous value") + } + + // Calling Watch again will produce vrbl2. + if err := w.Watch(ctx); err != nil { + t.Fatalf("Watcher.Watch() returned error: %v", err) + } + got = w.Variable() + want, err := pbToVariable(vrbl2) + if err != nil { + t.Fatal(err) + } + if diff := cmp.Diff(&got, want); diff != "" { + t.Errorf("Watcher.Variable(): %s", diff) + } +} + +func TestWatchCancelled(t *testing.T) { + client, cleanUp := setUp(t, &fakeServer{ + responses: []response{ + {vrbl: vrbl1}, + }, + }) + defer cleanUp() + + ctx := context.Background() + w, err := client.NewWatcher(ctx, "projectID", "config", "greetings", watchOpt) + if err != nil { + t.Fatalf("Client.NewWatcher() returned error: %v", err) + } + + ctx, cancel := context.WithCancel(ctx) + go func() { + time.Sleep(1 * time.Second) + cancel() }() + + if err := w.Watch(ctx); err != context.Canceled { + t.Fatalf("Watcher.Watch() returned %v, want context.Canceled", err) + } +} + +func TestWatchRPCError(t *testing.T) { + rpcErr := status.Error(codes.Internal, "bad") + client, cleanUp := setUp(t, &fakeServer{ + responses: []response{ + {vrbl: vrbl1}, + {err: rpcErr}, + }, + }) + defer cleanUp() + + ctx := context.Background() + w, err := client.NewWatcher(ctx, "projectID", "config", "greetings", watchOpt) + if err != nil { + t.Fatalf("Client.NewWatcher() returned error: %v", err) + } + + // Expect RPC error. + err = w.Watch(ctx) + if !strings.Contains(err.Error(), "bad") { + t.Errorf("Watcher.Watch() returned %v, want %v", err, rpcErr) + } + + // Variable should still be the same. + got := w.Variable() + want, err := pbToVariable(vrbl1) + if err != nil { + t.Fatal(err) + } + if diff := cmp.Diff(&got, want); diff != "" { + t.Errorf("Watcher.Variable(): %s", diff) + } }