* feat(suite): Implement Suites Fixes #1230 * Update docs * Fix variable alignment * Prevent always-run endpoint from running if a context placeholder fails to resolve in the URL * Return errors when a context placeholder path fails to resolve * Add a couple of unit tests * Add a couple of unit tests * fix(ui): Update group count properly Fixes #1233 * refactor: Pass down entire config instead of several sub-configs * fix: Change default suite interval and timeout * fix: Deprecate disable-monitoring-lock in favor of concurrency * fix: Make sure there are no duplicate keys * Refactor some code * Update watchdog/watchdog.go * Update web/app/src/components/StepDetailsModal.vue Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * chore: Remove useless log * fix: Set default concurrency to 3 instead of 5 --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
520 lines
18 KiB
Go
520 lines
18 KiB
Go
package api
|
|
|
|
import (
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/TwiN/gatus/v5/config"
|
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
|
"github.com/TwiN/gatus/v5/config/suite"
|
|
"github.com/TwiN/gatus/v5/storage"
|
|
"github.com/TwiN/gatus/v5/storage/store"
|
|
"github.com/TwiN/gatus/v5/watchdog"
|
|
)
|
|
|
|
var (
|
|
suiteTimestamp = time.Now()
|
|
|
|
testSuiteEndpoint1 = endpoint.Endpoint{
|
|
Name: "endpoint1",
|
|
Group: "suite-group",
|
|
URL: "https://example.org/endpoint1",
|
|
Method: "GET",
|
|
Interval: 30 * time.Second,
|
|
Conditions: []endpoint.Condition{endpoint.Condition("[STATUS] == 200"), endpoint.Condition("[RESPONSE_TIME] < 500")},
|
|
NumberOfFailuresInARow: 0,
|
|
NumberOfSuccessesInARow: 0,
|
|
}
|
|
testSuiteEndpoint2 = endpoint.Endpoint{
|
|
Name: "endpoint2",
|
|
Group: "suite-group",
|
|
URL: "https://example.org/endpoint2",
|
|
Method: "GET",
|
|
Interval: 30 * time.Second,
|
|
Conditions: []endpoint.Condition{endpoint.Condition("[STATUS] == 200"), endpoint.Condition("[RESPONSE_TIME] < 300")},
|
|
NumberOfFailuresInARow: 0,
|
|
NumberOfSuccessesInARow: 0,
|
|
}
|
|
testSuite = suite.Suite{
|
|
Name: "test-suite",
|
|
Group: "suite-group",
|
|
Interval: 60 * time.Second,
|
|
Endpoints: []*endpoint.Endpoint{
|
|
&testSuiteEndpoint1,
|
|
&testSuiteEndpoint2,
|
|
},
|
|
}
|
|
testSuccessfulSuiteResult = suite.Result{
|
|
Name: "test-suite",
|
|
Group: "suite-group",
|
|
Success: true,
|
|
Timestamp: suiteTimestamp,
|
|
Duration: 250 * time.Millisecond,
|
|
EndpointResults: []*endpoint.Result{
|
|
{
|
|
Hostname: "example.org",
|
|
IP: "127.0.0.1",
|
|
HTTPStatus: 200,
|
|
Success: true,
|
|
Timestamp: suiteTimestamp,
|
|
Duration: 100 * time.Millisecond,
|
|
ConditionResults: []*endpoint.ConditionResult{
|
|
{
|
|
Condition: "[STATUS] == 200",
|
|
Success: true,
|
|
},
|
|
{
|
|
Condition: "[RESPONSE_TIME] < 500",
|
|
Success: true,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Hostname: "example.org",
|
|
IP: "127.0.0.1",
|
|
HTTPStatus: 200,
|
|
Success: true,
|
|
Timestamp: suiteTimestamp,
|
|
Duration: 150 * time.Millisecond,
|
|
ConditionResults: []*endpoint.ConditionResult{
|
|
{
|
|
Condition: "[STATUS] == 200",
|
|
Success: true,
|
|
},
|
|
{
|
|
Condition: "[RESPONSE_TIME] < 300",
|
|
Success: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
testUnsuccessfulSuiteResult = suite.Result{
|
|
Name: "test-suite",
|
|
Group: "suite-group",
|
|
Success: false,
|
|
Timestamp: suiteTimestamp,
|
|
Duration: 850 * time.Millisecond,
|
|
Errors: []string{"suite-error-1", "suite-error-2"},
|
|
EndpointResults: []*endpoint.Result{
|
|
{
|
|
Hostname: "example.org",
|
|
IP: "127.0.0.1",
|
|
HTTPStatus: 200,
|
|
Success: true,
|
|
Timestamp: suiteTimestamp,
|
|
Duration: 100 * time.Millisecond,
|
|
ConditionResults: []*endpoint.ConditionResult{
|
|
{
|
|
Condition: "[STATUS] == 200",
|
|
Success: true,
|
|
},
|
|
{
|
|
Condition: "[RESPONSE_TIME] < 500",
|
|
Success: true,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Hostname: "example.org",
|
|
IP: "127.0.0.1",
|
|
HTTPStatus: 500,
|
|
Errors: []string{"endpoint-error-1"},
|
|
Success: false,
|
|
Timestamp: suiteTimestamp,
|
|
Duration: 750 * time.Millisecond,
|
|
ConditionResults: []*endpoint.ConditionResult{
|
|
{
|
|
Condition: "[STATUS] == 200",
|
|
Success: false,
|
|
},
|
|
{
|
|
Condition: "[RESPONSE_TIME] < 300",
|
|
Success: false,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
)
|
|
|
|
func TestSuiteStatus(t *testing.T) {
|
|
defer store.Get().Clear()
|
|
defer cache.Clear()
|
|
cfg := &config.Config{
|
|
Metrics: true,
|
|
Suites: []*suite.Suite{
|
|
{
|
|
Name: "frontend-suite",
|
|
Group: "core",
|
|
},
|
|
{
|
|
Name: "backend-suite",
|
|
Group: "core",
|
|
},
|
|
},
|
|
Storage: &storage.Config{
|
|
MaximumNumberOfResults: storage.DefaultMaximumNumberOfResults,
|
|
MaximumNumberOfEvents: storage.DefaultMaximumNumberOfEvents,
|
|
},
|
|
}
|
|
watchdog.UpdateSuiteStatus(cfg.Suites[0], &suite.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now(), Name: cfg.Suites[0].Name, Group: cfg.Suites[0].Group})
|
|
watchdog.UpdateSuiteStatus(cfg.Suites[1], &suite.Result{Success: false, Duration: time.Second, Timestamp: time.Now(), Name: cfg.Suites[1].Name, Group: cfg.Suites[1].Group})
|
|
api := New(cfg)
|
|
router := api.Router()
|
|
type Scenario struct {
|
|
Name string
|
|
Path string
|
|
ExpectedCode int
|
|
Gzip bool
|
|
}
|
|
scenarios := []Scenario{
|
|
{
|
|
Name: "suite-status",
|
|
Path: "/api/v1/suites/core_frontend-suite/statuses",
|
|
ExpectedCode: http.StatusOK,
|
|
},
|
|
{
|
|
Name: "suite-status-gzip",
|
|
Path: "/api/v1/suites/core_frontend-suite/statuses",
|
|
ExpectedCode: http.StatusOK,
|
|
Gzip: true,
|
|
},
|
|
{
|
|
Name: "suite-status-pagination",
|
|
Path: "/api/v1/suites/core_frontend-suite/statuses?page=1&pageSize=20",
|
|
ExpectedCode: http.StatusOK,
|
|
},
|
|
{
|
|
Name: "suite-status-for-invalid-key",
|
|
Path: "/api/v1/suites/invalid_key/statuses",
|
|
ExpectedCode: http.StatusNotFound,
|
|
},
|
|
}
|
|
for _, scenario := range scenarios {
|
|
t.Run(scenario.Name, func(t *testing.T) {
|
|
request := httptest.NewRequest("GET", scenario.Path, http.NoBody)
|
|
if scenario.Gzip {
|
|
request.Header.Set("Accept-Encoding", "gzip")
|
|
}
|
|
response, err := router.Test(request)
|
|
if err != nil {
|
|
return
|
|
}
|
|
if response.StatusCode != scenario.ExpectedCode {
|
|
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, response.StatusCode)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSuiteStatus_SuiteNotInStoreButInConfig(t *testing.T) {
|
|
defer store.Get().Clear()
|
|
defer cache.Clear()
|
|
tests := []struct {
|
|
name string
|
|
suiteKey string
|
|
cfg *config.Config
|
|
expectedCode int
|
|
expectJSON bool
|
|
expectError string
|
|
}{
|
|
{
|
|
name: "suite-not-in-store-but-exists-in-config-enabled",
|
|
suiteKey: "test-group_test-suite",
|
|
cfg: &config.Config{
|
|
Metrics: true,
|
|
Suites: []*suite.Suite{
|
|
{
|
|
Name: "test-suite",
|
|
Group: "test-group",
|
|
Enabled: boolPtr(true),
|
|
Endpoints: []*endpoint.Endpoint{
|
|
{
|
|
Name: "endpoint-1",
|
|
Group: "test-group",
|
|
URL: "https://example.com",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
Storage: &storage.Config{
|
|
MaximumNumberOfResults: storage.DefaultMaximumNumberOfResults,
|
|
MaximumNumberOfEvents: storage.DefaultMaximumNumberOfEvents,
|
|
},
|
|
},
|
|
expectedCode: http.StatusOK,
|
|
expectJSON: true,
|
|
},
|
|
{
|
|
name: "suite-not-in-store-but-exists-in-config-disabled",
|
|
suiteKey: "test-group_disabled-suite",
|
|
cfg: &config.Config{
|
|
Metrics: true,
|
|
Suites: []*suite.Suite{
|
|
{
|
|
Name: "disabled-suite",
|
|
Group: "test-group",
|
|
Enabled: boolPtr(false),
|
|
},
|
|
},
|
|
Storage: &storage.Config{
|
|
MaximumNumberOfResults: storage.DefaultMaximumNumberOfResults,
|
|
MaximumNumberOfEvents: storage.DefaultMaximumNumberOfEvents,
|
|
},
|
|
},
|
|
expectedCode: http.StatusOK,
|
|
expectJSON: true,
|
|
},
|
|
{
|
|
name: "suite-not-in-store-and-not-in-config",
|
|
suiteKey: "nonexistent_suite",
|
|
cfg: &config.Config{
|
|
Metrics: true,
|
|
Suites: []*suite.Suite{
|
|
{
|
|
Name: "different-suite",
|
|
Group: "different-group",
|
|
},
|
|
},
|
|
Storage: &storage.Config{
|
|
MaximumNumberOfResults: storage.DefaultMaximumNumberOfResults,
|
|
MaximumNumberOfEvents: storage.DefaultMaximumNumberOfEvents,
|
|
},
|
|
},
|
|
expectedCode: http.StatusNotFound,
|
|
expectError: "Suite with key 'nonexistent_suite' not found",
|
|
},
|
|
{
|
|
name: "suite-with-empty-group-in-config",
|
|
suiteKey: "_empty-group-suite",
|
|
cfg: &config.Config{
|
|
Metrics: true,
|
|
Suites: []*suite.Suite{
|
|
{
|
|
Name: "empty-group-suite",
|
|
Group: "",
|
|
},
|
|
},
|
|
Storage: &storage.Config{
|
|
MaximumNumberOfResults: storage.DefaultMaximumNumberOfResults,
|
|
MaximumNumberOfEvents: storage.DefaultMaximumNumberOfEvents,
|
|
},
|
|
},
|
|
expectedCode: http.StatusOK,
|
|
expectJSON: true,
|
|
},
|
|
{
|
|
name: "suite-nil-enabled-defaults-to-true",
|
|
suiteKey: "default_enabled-suite",
|
|
cfg: &config.Config{
|
|
Metrics: true,
|
|
Suites: []*suite.Suite{
|
|
{
|
|
Name: "enabled-suite",
|
|
Group: "default",
|
|
Enabled: nil,
|
|
},
|
|
},
|
|
Storage: &storage.Config{
|
|
MaximumNumberOfResults: storage.DefaultMaximumNumberOfResults,
|
|
MaximumNumberOfEvents: storage.DefaultMaximumNumberOfEvents,
|
|
},
|
|
},
|
|
expectedCode: http.StatusOK,
|
|
expectJSON: true,
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
api := New(tt.cfg)
|
|
router := api.Router()
|
|
request := httptest.NewRequest("GET", "/api/v1/suites/"+tt.suiteKey+"/statuses", http.NoBody)
|
|
response, err := router.Test(request)
|
|
if err != nil {
|
|
t.Fatalf("Router test failed: %v", err)
|
|
}
|
|
defer response.Body.Close()
|
|
if response.StatusCode != tt.expectedCode {
|
|
t.Errorf("Expected status code %d, got %d", tt.expectedCode, response.StatusCode)
|
|
}
|
|
body, err := io.ReadAll(response.Body)
|
|
if err != nil {
|
|
t.Fatalf("Failed to read response body: %v", err)
|
|
}
|
|
bodyStr := string(body)
|
|
if tt.expectJSON {
|
|
if response.Header.Get("Content-Type") != "application/json" {
|
|
t.Errorf("Expected JSON content type, got %s", response.Header.Get("Content-Type"))
|
|
}
|
|
if len(bodyStr) == 0 || bodyStr[0] != '{' {
|
|
t.Errorf("Expected JSON response, got: %s", bodyStr)
|
|
}
|
|
}
|
|
if tt.expectError != "" {
|
|
if !contains(bodyStr, tt.expectError) {
|
|
t.Errorf("Expected error message '%s' in response, got: %s", tt.expectError, bodyStr)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSuiteStatuses(t *testing.T) {
|
|
defer store.Get().Clear()
|
|
defer cache.Clear()
|
|
firstResult := &testSuccessfulSuiteResult
|
|
secondResult := &testUnsuccessfulSuiteResult
|
|
store.Get().InsertSuiteResult(&testSuite, firstResult)
|
|
store.Get().InsertSuiteResult(&testSuite, secondResult)
|
|
// Can't be bothered dealing with timezone issues on the worker that runs the automated tests
|
|
firstResult.Timestamp = time.Time{}
|
|
secondResult.Timestamp = time.Time{}
|
|
for i := range firstResult.EndpointResults {
|
|
firstResult.EndpointResults[i].Timestamp = time.Time{}
|
|
}
|
|
for i := range secondResult.EndpointResults {
|
|
secondResult.EndpointResults[i].Timestamp = time.Time{}
|
|
}
|
|
api := New(&config.Config{
|
|
Metrics: true,
|
|
Storage: &storage.Config{
|
|
MaximumNumberOfResults: storage.DefaultMaximumNumberOfResults,
|
|
MaximumNumberOfEvents: storage.DefaultMaximumNumberOfEvents,
|
|
},
|
|
})
|
|
router := api.Router()
|
|
type Scenario struct {
|
|
Name string
|
|
Path string
|
|
ExpectedCode int
|
|
ExpectedBody string
|
|
}
|
|
scenarios := []Scenario{
|
|
{
|
|
Name: "no-pagination",
|
|
Path: "/api/v1/suites/statuses",
|
|
ExpectedCode: http.StatusOK,
|
|
ExpectedBody: `[{"name":"test-suite","group":"suite-group","key":"suite-group_test-suite","results":[{"name":"test-suite","group":"suite-group","success":true,"timestamp":"0001-01-01T00:00:00Z","duration":250000000,"endpointResults":[{"status":200,"hostname":"example.org","duration":100000000,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"},{"status":200,"hostname":"example.org","duration":150000000,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 300","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"}]},{"name":"test-suite","group":"suite-group","success":false,"timestamp":"0001-01-01T00:00:00Z","duration":850000000,"endpointResults":[{"status":200,"hostname":"example.org","duration":100000000,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"},{"status":500,"hostname":"example.org","duration":750000000,"errors":["endpoint-error-1"],"conditionResults":[{"condition":"[STATUS] == 200","success":false},{"condition":"[RESPONSE_TIME] \u003c 300","success":false}],"success":false,"timestamp":"0001-01-01T00:00:00Z"}],"errors":["suite-error-1","suite-error-2"]}]}]`,
|
|
},
|
|
{
|
|
Name: "pagination-first-result",
|
|
Path: "/api/v1/suites/statuses?page=1&pageSize=1",
|
|
ExpectedCode: http.StatusOK,
|
|
ExpectedBody: `[{"name":"test-suite","group":"suite-group","key":"suite-group_test-suite","results":[{"name":"test-suite","group":"suite-group","success":false,"timestamp":"0001-01-01T00:00:00Z","duration":850000000,"endpointResults":[{"status":200,"hostname":"example.org","duration":100000000,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"},{"status":500,"hostname":"example.org","duration":750000000,"errors":["endpoint-error-1"],"conditionResults":[{"condition":"[STATUS] == 200","success":false},{"condition":"[RESPONSE_TIME] \u003c 300","success":false}],"success":false,"timestamp":"0001-01-01T00:00:00Z"}],"errors":["suite-error-1","suite-error-2"]}]}]`,
|
|
},
|
|
{
|
|
Name: "pagination-second-result",
|
|
Path: "/api/v1/suites/statuses?page=2&pageSize=1",
|
|
ExpectedCode: http.StatusOK,
|
|
ExpectedBody: `[{"name":"test-suite","group":"suite-group","key":"suite-group_test-suite","results":[{"name":"test-suite","group":"suite-group","success":true,"timestamp":"0001-01-01T00:00:00Z","duration":250000000,"endpointResults":[{"status":200,"hostname":"example.org","duration":100000000,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"},{"status":200,"hostname":"example.org","duration":150000000,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 300","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"}]}]}]`,
|
|
},
|
|
{
|
|
Name: "pagination-no-results",
|
|
Path: "/api/v1/suites/statuses?page=5&pageSize=20",
|
|
ExpectedCode: http.StatusOK,
|
|
ExpectedBody: `[{"name":"test-suite","group":"suite-group","key":"suite-group_test-suite","results":[]}]`,
|
|
},
|
|
{
|
|
Name: "invalid-pagination-should-fall-back-to-default",
|
|
Path: "/api/v1/suites/statuses?page=INVALID&pageSize=INVALID",
|
|
ExpectedCode: http.StatusOK,
|
|
ExpectedBody: `[{"name":"test-suite","group":"suite-group","key":"suite-group_test-suite","results":[{"name":"test-suite","group":"suite-group","success":true,"timestamp":"0001-01-01T00:00:00Z","duration":250000000,"endpointResults":[{"status":200,"hostname":"example.org","duration":100000000,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"},{"status":200,"hostname":"example.org","duration":150000000,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 300","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"}]},{"name":"test-suite","group":"suite-group","success":false,"timestamp":"0001-01-01T00:00:00Z","duration":850000000,"endpointResults":[{"status":200,"hostname":"example.org","duration":100000000,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"},{"status":500,"hostname":"example.org","duration":750000000,"errors":["endpoint-error-1"],"conditionResults":[{"condition":"[STATUS] == 200","success":false},{"condition":"[RESPONSE_TIME] \u003c 300","success":false}],"success":false,"timestamp":"0001-01-01T00:00:00Z"}],"errors":["suite-error-1","suite-error-2"]}]}]`,
|
|
},
|
|
}
|
|
for _, scenario := range scenarios {
|
|
t.Run(scenario.Name, func(t *testing.T) {
|
|
request := httptest.NewRequest("GET", scenario.Path, http.NoBody)
|
|
response, err := router.Test(request)
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer response.Body.Close()
|
|
if response.StatusCode != scenario.ExpectedCode {
|
|
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, response.StatusCode)
|
|
}
|
|
body, err := io.ReadAll(response.Body)
|
|
if err != nil {
|
|
t.Error("expected err to be nil, but was", err)
|
|
}
|
|
if string(body) != scenario.ExpectedBody {
|
|
t.Errorf("expected:\n %s\n\ngot:\n %s", scenario.ExpectedBody, string(body))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSuiteStatuses_NoSuitesInStoreButExistInConfig(t *testing.T) {
|
|
defer store.Get().Clear()
|
|
defer cache.Clear()
|
|
cfg := &config.Config{
|
|
Metrics: true,
|
|
Suites: []*suite.Suite{
|
|
{
|
|
Name: "config-only-suite-1",
|
|
Group: "test-group",
|
|
Enabled: boolPtr(true),
|
|
},
|
|
{
|
|
Name: "config-only-suite-2",
|
|
Group: "test-group",
|
|
Enabled: boolPtr(true),
|
|
},
|
|
{
|
|
Name: "disabled-suite",
|
|
Group: "test-group",
|
|
Enabled: boolPtr(false),
|
|
},
|
|
},
|
|
Storage: &storage.Config{
|
|
MaximumNumberOfResults: storage.DefaultMaximumNumberOfResults,
|
|
MaximumNumberOfEvents: storage.DefaultMaximumNumberOfEvents,
|
|
},
|
|
}
|
|
api := New(cfg)
|
|
router := api.Router()
|
|
request := httptest.NewRequest("GET", "/api/v1/suites/statuses", http.NoBody)
|
|
response, err := router.Test(request)
|
|
if err != nil {
|
|
t.Fatalf("Router test failed: %v", err)
|
|
}
|
|
defer response.Body.Close()
|
|
if response.StatusCode != http.StatusOK {
|
|
t.Errorf("Expected status code %d, got %d", http.StatusOK, response.StatusCode)
|
|
}
|
|
body, err := io.ReadAll(response.Body)
|
|
if err != nil {
|
|
t.Fatalf("Failed to read response body: %v", err)
|
|
}
|
|
bodyStr := string(body)
|
|
if !contains(bodyStr, "config-only-suite-1") {
|
|
t.Error("Expected config-only-suite-1 in response")
|
|
}
|
|
if !contains(bodyStr, "config-only-suite-2") {
|
|
t.Error("Expected config-only-suite-2 in response")
|
|
}
|
|
if contains(bodyStr, "disabled-suite") {
|
|
t.Error("Should not include disabled-suite in response")
|
|
}
|
|
}
|
|
|
|
func boolPtr(b bool) *bool {
|
|
return &b
|
|
}
|
|
|
|
func contains(s, substr string) bool {
|
|
return len(s) >= len(substr) && (s == substr || len(substr) == 0 ||
|
|
(len(s) > len(substr) && (s[:len(substr)] == substr || s[len(s)-len(substr):] == substr ||
|
|
func() bool {
|
|
for i := 1; i <= len(s)-len(substr); i++ {
|
|
if s[i:i+len(substr)] == substr {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}())))
|
|
}
|