mirror of
https://github.com/featurebasedb/featurebase.git
synced 2026-09-06 08:35:55 +00:00
The testhook/ package provides an easy way to set up multiple hooks to run before/after tests are run. The audit hooks track open and closes of storage backends, files, indexes, and holders, for example. A tempdir wrapper creates temporary directories which are automatically cleaned up when the test ends. Any kind of resource creation that should be closed at test conclusion can be tracked. We will complain at the end of the TestMain if resources are leaking. Leaks under go1.13: We use a wrapper function which is a no-op for go 1.13, but actually calls testing.TB.Cleanup in go1.14, so we can still build with 1.13 even though tests will leak files all over the place there. Because of this, don't run the testhook tests when using 1.13, as they'll always fail. - the test/pilosa.go http client now times out after 10 seconds to help diagnose hung server situations. - Makefile targets added to get better progress reports.
368 lines
11 KiB
Go
368 lines
11 KiB
Go
// Copyright 2017 Pilosa Corp.
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"io/ioutil"
|
|
gohttp "net/http"
|
|
"os"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/pilosa/pilosa/v2"
|
|
"github.com/pilosa/pilosa/v2/encoding/proto"
|
|
"github.com/pilosa/pilosa/v2/http"
|
|
"github.com/pilosa/pilosa/v2/server"
|
|
"github.com/pilosa/pilosa/v2/testhook"
|
|
)
|
|
|
|
////////////////////////////////////////////////////////////////////////////////////
|
|
// Command represents a test wrapper for server.Command.
|
|
type Command struct {
|
|
*server.Command
|
|
|
|
commandOptions []server.CommandOption
|
|
}
|
|
|
|
func OptAllowedOrigins(origins []string) server.CommandOption {
|
|
return func(m *server.Command) error {
|
|
m.Config.Handler.AllowedOrigins = origins
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// newCommand returns a new instance of Main with a temporary data directory and random port.
|
|
func newCommand(tb testing.TB, opts ...server.CommandOption) *Command {
|
|
path, err := testhook.TempDir(tb, "pilosa-command-")
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
// Set aggressive close timeout by default to avoid hanging tests. This was
|
|
// a problem with PDK tests which used go-pilosa as well. We put it at the
|
|
// beginning of the option slice so that it can be overridden by user-passed
|
|
// options.
|
|
// Also set TranslateFile MapSize to a smaller number so memory allocation
|
|
// does not fail on 32-bit systems.
|
|
opts = append([]server.CommandOption{
|
|
server.OptCommandCloseTimeout(time.Millisecond * 2),
|
|
}, opts...)
|
|
m := &Command{commandOptions: opts}
|
|
m.Command = server.NewCommand(bytes.NewReader(nil), ioutil.Discard, ioutil.Discard, opts...)
|
|
m.Config.DataDir = path
|
|
defaultConf := server.NewConfig()
|
|
if m.Config.Bind == defaultConf.Bind {
|
|
m.Config.Bind = "http://localhost:0"
|
|
}
|
|
if m.Config.BindGRPC == defaultConf.BindGRPC {
|
|
m.Config.BindGRPC = "http://localhost:0"
|
|
}
|
|
m.Config.Translation.MapSize = 140000
|
|
m.Config.WorkerPoolSize = 2
|
|
|
|
if testing.Verbose() {
|
|
m.Command.Stdout = os.Stdout
|
|
m.Command.Stderr = os.Stderr
|
|
}
|
|
|
|
return m
|
|
}
|
|
|
|
// NewCommandNode returns a new instance of Command with clustering enabled.
|
|
func NewCommandNode(tb testing.TB, isCoordinator bool, opts ...server.CommandOption) *Command {
|
|
// We want tests to default to using the in-memory translate store, so we
|
|
// prepend opts with that functional option. If a different translate store
|
|
// has been specified, it will override this one.
|
|
opts = prependTestServerOpts(opts)
|
|
m := newCommand(tb, opts...)
|
|
m.Config.Cluster.Disabled = false
|
|
m.Config.Cluster.Coordinator = isCoordinator
|
|
return m
|
|
}
|
|
|
|
// RunCommand returns a new, running Main. Panic on error.
|
|
func RunCommand(t *testing.T) *Command {
|
|
t.Helper()
|
|
m := newCommand(t, server.OptCommandServerOptions(pilosa.OptServerOpenTranslateStore(pilosa.OpenInMemTranslateStore)))
|
|
m.Config.Metric.Diagnostics = false // Disable diagnostics.
|
|
m.Config.Gossip.Port = "0"
|
|
if err := m.Start(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return m
|
|
}
|
|
|
|
// GossipAddress returns the address on which gossip is listening after a Main
|
|
// has been setup. Useful to pass as a seed to other nodes when creating and
|
|
// testing clusters.
|
|
func (m *Command) GossipAddress() string {
|
|
return m.GossipTransport().URI.String()
|
|
}
|
|
|
|
// Close closes the program and removes the underlying data directory.
|
|
func (m *Command) Close() error {
|
|
defer os.RemoveAll(m.Config.DataDir)
|
|
return m.Command.Close()
|
|
}
|
|
|
|
// Reopen closes the program and reopens it.
|
|
func (m *Command) Reopen() error {
|
|
if err := m.Command.Close(); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Create new main with the same config.
|
|
config := m.Command.Config
|
|
m.Command = server.NewCommand(bytes.NewReader(nil), ioutil.Discard, ioutil.Discard, m.commandOptions...)
|
|
m.Command.Config = config
|
|
|
|
// Run new program.
|
|
return m.Start()
|
|
}
|
|
|
|
// SoftOpen is like Reopen, but doesn't close the program first.
|
|
// This is useful in the case where a test needs to decouple the
|
|
// close from the re-open (for example, there may need to be
|
|
// actions which take place between those two steps).
|
|
func (m *Command) SoftOpen() error {
|
|
// Create new main with the same config.
|
|
config := m.Command.Config
|
|
m.Command = server.NewCommand(bytes.NewReader(nil), ioutil.Discard, ioutil.Discard, m.commandOptions...)
|
|
m.Command.Config = config
|
|
|
|
// Run new program.
|
|
return m.Start()
|
|
}
|
|
|
|
// MustCreateIndex uses this command's API to create an index and fails the test
|
|
// if there is an error.
|
|
func (m *Command) MustCreateIndex(tb testing.TB, name string, opts pilosa.IndexOptions) *pilosa.Index {
|
|
tb.Helper()
|
|
idx, err := m.API.CreateIndex(context.Background(), name, opts)
|
|
if err != nil {
|
|
tb.Fatalf("creating index: %v with options: %v, err: %v", name, opts, err)
|
|
}
|
|
return idx
|
|
}
|
|
|
|
// MustCreateField uses this command's API to create the field. The index must
|
|
// already exist - it fails the test if there is an error.
|
|
func (m *Command) MustCreateField(tb testing.TB, index, field string, opts ...pilosa.FieldOption) *pilosa.Field {
|
|
tb.Helper()
|
|
f, err := m.API.CreateField(context.Background(), index, field, opts...)
|
|
if err != nil {
|
|
tb.Fatalf("creating field: %s in index: %s err: %v", field, index, err)
|
|
}
|
|
return f
|
|
}
|
|
|
|
// QueryAPI uses this command's API to execute the given query request, failing
|
|
// if Query returns a non-nil error, otherwise returning the QueryResponse.
|
|
func (m *Command) QueryAPI(tb testing.TB, req *pilosa.QueryRequest) pilosa.QueryResponse {
|
|
tb.Helper()
|
|
resp, err := m.API.Query(context.Background(), req)
|
|
if err != nil {
|
|
tb.Fatalf("making query: %v, err: %v", req, err)
|
|
}
|
|
return resp
|
|
}
|
|
|
|
// MustRecalculateCaches calls RecalculateCaches on the command's API, and fails
|
|
// if there is an error.
|
|
func (m *Command) MustRecalculateCaches(tb testing.TB) {
|
|
err := m.API.RecalculateCaches(context.Background())
|
|
if err != nil {
|
|
tb.Fatalf("recalcluating caches: %v", err)
|
|
}
|
|
}
|
|
|
|
// URL returns the base URL string for accessing the running program.
|
|
func (m *Command) URL() string { return m.API.Node().URI.String() }
|
|
|
|
// ID returns the node ID used by the running program.
|
|
func (m *Command) ID() string { return m.API.Node().ID }
|
|
|
|
// Client returns a client to connect to the program.
|
|
func (m *Command) Client() *http.InternalClient {
|
|
return m.Server.InternalClient().(*http.InternalClient)
|
|
}
|
|
|
|
// Query executes a query against the program through the HTTP API.
|
|
func (m *Command) Query(t *testing.T, index, rawQuery, query string) (string, error) {
|
|
resp := Do(t, "POST", fmt.Sprintf("%s/index/%s/query?%s", m.URL(), index, rawQuery), query)
|
|
if resp.StatusCode != gohttp.StatusOK {
|
|
return "", fmt.Errorf("invalid status: %d, body=%s", resp.StatusCode, resp.Body)
|
|
}
|
|
return resp.Body, nil
|
|
}
|
|
|
|
// Queryf is like Query, but with a format string.
|
|
func (m *Command) Queryf(t *testing.T, index, rawQuery, query string, params ...interface{}) (string, error) {
|
|
query = fmt.Sprintf(query, params...)
|
|
resp := Do(t, "POST", fmt.Sprintf("%s/index/%s/query?%s", m.URL(), index, rawQuery), query)
|
|
if resp.StatusCode != gohttp.StatusOK {
|
|
return "", fmt.Errorf("invalid status: %d, body=%s", resp.StatusCode, resp.Body)
|
|
}
|
|
return resp.Body, nil
|
|
}
|
|
|
|
// QueryExpect executes a query against the program through the HTTP API, and
|
|
// confirms that it got an expected response.
|
|
func (m *Command) QueryExpect(t *testing.T, index, rawQuery, query string, expected string) {
|
|
resp := Do(t, "POST", fmt.Sprintf("%s/index/%s/query?%s", m.URL(), index, rawQuery), query)
|
|
if resp.StatusCode != gohttp.StatusOK {
|
|
t.Fatalf("invalid status from %s: %d, body=%q", m.ID(), resp.StatusCode, resp.Body)
|
|
}
|
|
last := len(resp.Body) - 1
|
|
// Trim trailing newline so we don't need it to be present in the expected data.
|
|
if last >= 0 && resp.Body[last] == '\n' {
|
|
resp.Body = resp.Body[:last]
|
|
}
|
|
|
|
if resp.Body != expected {
|
|
t.Fatalf("node %s, query %q: expected response %s, got %s", m.ID(), query, expected, resp.Body)
|
|
}
|
|
}
|
|
|
|
func (m *Command) QueryProtobuf(indexName string, query string) (*pilosa.QueryResponse, error) {
|
|
var ser proto.Serializer
|
|
queryReq := &pilosa.QueryRequest{
|
|
Index: indexName,
|
|
Query: query,
|
|
}
|
|
body, err := ser.Marshal(queryReq)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
req, err := gohttp.NewRequest(
|
|
"POST",
|
|
fmt.Sprintf("%s/index/%s/query", m.URL(), indexName),
|
|
bytes.NewReader(body),
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
req.Header.Set("Content-Type", "application/x-protobuf")
|
|
req.Header.Set("Accept", "application/x-protobuf")
|
|
|
|
resp, err := gohttp.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
buf, err := ioutil.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
response := &pilosa.QueryResponse{}
|
|
err = ser.Unmarshal(buf, response)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return response, nil
|
|
}
|
|
|
|
// RecalculateCaches is deprecated. Use MustRecalculateCaches.
|
|
func (m *Command) RecalculateCaches(t *testing.T) error {
|
|
resp := Do(t, "POST", fmt.Sprintf("%s/recalculate-caches", m.URL()), "")
|
|
if resp.StatusCode != 204 {
|
|
return fmt.Errorf("invalid status: %d, body=%s", resp.StatusCode, resp.Body)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Do executes http.Do() with an http.NewRequest().
|
|
func Do(t *testing.T, method, urlStr string, body string) *httpResponse {
|
|
t.Helper()
|
|
req, err := gohttp.NewRequest(
|
|
method,
|
|
urlStr,
|
|
strings.NewReader(body),
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
// set a timeout instead of allowing gohttp.Defaultclient to
|
|
// potentially hang forever.
|
|
hc := &gohttp.Client{
|
|
Timeout: time.Second * 10,
|
|
}
|
|
resp, err := hc.Do(req)
|
|
|
|
if err != nil {
|
|
fmt.Printf(" hc.Do() err = '%v'\n", err)
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
buf, err := ioutil.ReadAll(resp.Body)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
return &httpResponse{Response: resp, Body: string(buf)}
|
|
}
|
|
|
|
func CheckGroupBy(t *testing.T, expected, results []pilosa.GroupCount) {
|
|
t.Helper()
|
|
if len(results) != len(expected) {
|
|
t.Fatalf("number of groupings mismatch:\n got:%+v\nwant:%+v\n", results, expected)
|
|
}
|
|
for i, result := range results {
|
|
if !reflect.DeepEqual(expected[i], result) {
|
|
t.Fatalf("unexpected result at %d: \n got:%+v\nwant:%+v\n", i, result, expected[i])
|
|
}
|
|
}
|
|
}
|
|
|
|
// httpResponse is a wrapper for http.Response that holds the Body as a string.
|
|
type httpResponse struct {
|
|
*gohttp.Response
|
|
Body string
|
|
}
|
|
|
|
// RetryUntil repeatedly executes fn until it returns nil or timeout occurs.
|
|
func RetryUntil(timeout time.Duration, fn func() error) (err error) {
|
|
timer := time.NewTimer(timeout)
|
|
defer timer.Stop()
|
|
ticker := time.NewTicker(10 * time.Millisecond)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
if err = fn(); err == nil {
|
|
return nil
|
|
}
|
|
|
|
select {
|
|
case <-timer.C:
|
|
return err
|
|
case <-ticker.C:
|
|
}
|
|
}
|
|
}
|