open-consul/command/config/write/config_write_test.go

1394 lines
26 KiB
Go

package write
import (
"io"
"os"
"strings"
"testing"
"time"
"github.com/hashicorp/consul/agent"
"github.com/hashicorp/consul/api"
"github.com/hashicorp/consul/sdk/testutil"
"github.com/mitchellh/cli"
"github.com/stretchr/testify/require"
)
func TestConfigWrite_noTabs(t *testing.T) {
t.Parallel()
require.NotContains(t, New(cli.NewMockUi()).Help(), "\t")
}
func TestConfigWrite(t *testing.T) {
t.Parallel()
a := agent.NewTestAgent(t, ``)
defer a.Shutdown()
client := a.Client()
t.Run("File", func(t *testing.T) {
ui := cli.NewMockUi()
c := New(ui)
f := testutil.TempFile(t, "config-write-svc-web.hcl")
defer os.Remove(f.Name())
_, err := f.WriteString(`
Kind = "service-defaults"
Name = "web"
Protocol = "udp"
`)
require.NoError(t, err)
args := []string{
"-http-addr=" + a.HTTPAddr(),
f.Name(),
}
code := c.Run(args)
require.Empty(t, ui.ErrorWriter.String())
require.Equal(t, 0, code)
entry, _, err := client.ConfigEntries().Get("service-defaults", "web", nil)
require.NoError(t, err)
svc, ok := entry.(*api.ServiceConfigEntry)
require.True(t, ok)
require.Equal(t, api.ServiceDefaults, svc.Kind)
require.Equal(t, "web", svc.Name)
require.Equal(t, "udp", svc.Protocol)
})
t.Run("Stdin", func(t *testing.T) {
stdinR, stdinW := io.Pipe()
ui := cli.NewMockUi()
c := New(ui)
c.testStdin = stdinR
go func() {
stdinW.Write([]byte(`{
"Kind": "proxy-defaults",
"Name": "global",
"Config": {
"foo": "bar",
"bar": 1.0
}
}`))
stdinW.Close()
}()
args := []string{
"-http-addr=" + a.HTTPAddr(),
"-",
}
code := c.Run(args)
require.Empty(t, ui.ErrorWriter.String())
require.Equal(t, 0, code)
entry, _, err := client.ConfigEntries().Get(api.ProxyDefaults, api.ProxyConfigGlobal, nil)
require.NoError(t, err)
proxy, ok := entry.(*api.ProxyConfigEntry)
require.True(t, ok)
require.Equal(t, api.ProxyDefaults, proxy.Kind)
require.Equal(t, api.ProxyConfigGlobal, proxy.Name)
require.Equal(t, map[string]interface{}{"foo": "bar", "bar": 1.0}, proxy.Config)
})
t.Run("No config", func(t *testing.T) {
ui := cli.NewMockUi()
c := New(ui)
code := c.Run([]string{})
require.NotEqual(t, 0, code)
require.NotEmpty(t, ui.ErrorWriter.String())
})
}
// TestParseConfigEntry is the 'api' mirror image of
// agent/structs/config_entry_test.go:TestDecodeConfigEntry
func TestParseConfigEntry(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
camel, camelJSON string
snake, snakeJSON string
expect api.ConfigEntry
expectJSON api.ConfigEntry
expectErr string
}{
{
name: "proxy-defaults: extra fields or typo",
snake: `
kind = "proxy-defaults"
name = "main"
cornfig {
"foo" = 19
}
`,
camel: `
Kind = "proxy-defaults"
Name = "main"
Cornfig {
"foo" = 19
}
`,
snakeJSON: `
{
"kind": "proxy-defaults",
"name": "main",
"cornfig": {
"foo": 19
}
}
`,
camelJSON: `
{
"Kind": "proxy-defaults",
"Name": "main",
"Cornfig": {
"foo": 19
}
}
`,
expectErr: `invalid config key "cornfig"`,
},
{
name: "proxy-defaults",
snake: `
kind = "proxy-defaults"
name = "main"
config {
"foo" = 19
"bar" = "abc"
"moreconfig" {
"moar" = "config"
}
}
mesh_gateway {
mode = "remote"
}
`,
camel: `
Kind = "proxy-defaults"
Name = "main"
Config {
"foo" = 19
"bar" = "abc"
"moreconfig" {
"moar" = "config"
}
}
MeshGateway {
Mode = "remote"
}
`,
snakeJSON: `
{
"kind": "proxy-defaults",
"name": "main",
"config": {
"foo": 19,
"bar": "abc",
"moreconfig": {
"moar": "config"
}
},
"mesh_gateway": {
"mode": "remote"
}
}
`,
camelJSON: `
{
"Kind": "proxy-defaults",
"Name": "main",
"Config": {
"foo": 19,
"bar": "abc",
"moreconfig": {
"moar": "config"
}
},
"MeshGateway": {
"Mode": "remote"
}
}
`,
expect: &api.ProxyConfigEntry{
Kind: "proxy-defaults",
Name: "main",
Config: map[string]interface{}{
"foo": 19,
"bar": "abc",
"moreconfig": map[string]interface{}{
"moar": "config",
},
},
MeshGateway: api.MeshGatewayConfig{
Mode: api.MeshGatewayModeRemote,
},
},
expectJSON: &api.ProxyConfigEntry{
Kind: "proxy-defaults",
Name: "main",
Config: map[string]interface{}{
"foo": float64(19), // json decoding gives float64 instead of int here
"bar": "abc",
"moreconfig": map[string]interface{}{
"moar": "config",
},
},
MeshGateway: api.MeshGatewayConfig{
Mode: api.MeshGatewayModeRemote,
},
},
},
{
name: "terminating-gateway",
snake: `
kind = "terminating-gateway"
name = "terminating-gw-west"
namespace = "default"
services = [
{
name = "billing"
namespace = "biz"
ca_file = "/etc/ca.crt"
cert_file = "/etc/client.crt"
key_file = "/etc/tls.key"
},
{
name = "*"
namespace = "ops"
}
]
`,
camel: `
Kind = "terminating-gateway"
Name = "terminating-gw-west"
Namespace = "default"
Services = [
{
Name = "billing"
Namespace = "biz"
CAFile = "/etc/ca.crt"
CertFile = "/etc/client.crt"
KeyFile = "/etc/tls.key"
},
{
Name = "*"
Namespace = "ops"
}
]
`,
snakeJSON: `
{
"kind": "terminating-gateway",
"name": "terminating-gw-west",
"namespace": "default",
"services": [
{
"name": "billing",
"namespace": "biz",
"ca_file": "/etc/ca.crt",
"cert_file": "/etc/client.crt",
"key_file": "/etc/tls.key"
},
{
"name": "*",
"namespace": "ops"
}
]
}
`,
camelJSON: `
{
"Kind": "terminating-gateway",
"Name": "terminating-gw-west",
"Namespace": "default",
"Services": [
{
"Name": "billing",
"Namespace": "biz",
"CAFile": "/etc/ca.crt",
"CertFile": "/etc/client.crt",
"KeyFile": "/etc/tls.key"
},
{
"Name": "*",
"Namespace": "ops"
}
]
}
`,
expect: &api.TerminatingGatewayConfigEntry{
Kind: "terminating-gateway",
Name: "terminating-gw-west",
Namespace: "default",
Services: []api.LinkedService{
{
Name: "billing",
Namespace: "biz",
CAFile: "/etc/ca.crt",
CertFile: "/etc/client.crt",
KeyFile: "/etc/tls.key",
},
{
Name: "*",
Namespace: "ops",
},
},
},
expectJSON: &api.TerminatingGatewayConfigEntry{
Kind: "terminating-gateway",
Name: "terminating-gw-west",
Namespace: "default",
Services: []api.LinkedService{
{
Name: "billing",
Namespace: "biz",
CAFile: "/etc/ca.crt",
CertFile: "/etc/client.crt",
KeyFile: "/etc/tls.key",
},
{
Name: "*",
Namespace: "ops",
},
},
},
},
{
name: "service-defaults",
snake: `
kind = "service-defaults"
name = "main"
protocol = "http"
external_sni = "abc-123"
mesh_gateway {
mode = "remote"
}
`,
camel: `
Kind = "service-defaults"
Name = "main"
Protocol = "http"
ExternalSNI = "abc-123"
MeshGateway {
Mode = "remote"
}
`,
snakeJSON: `
{
"kind": "service-defaults",
"name": "main",
"protocol": "http",
"external_sni": "abc-123",
"mesh_gateway": {
"mode": "remote"
}
}
`,
camelJSON: `
{
"Kind": "service-defaults",
"Name": "main",
"Protocol": "http",
"ExternalSNI": "abc-123",
"MeshGateway": {
"Mode": "remote"
}
}
`,
expect: &api.ServiceConfigEntry{
Kind: "service-defaults",
Name: "main",
Protocol: "http",
ExternalSNI: "abc-123",
MeshGateway: api.MeshGatewayConfig{
Mode: api.MeshGatewayModeRemote,
},
},
},
{
name: "service-router: kitchen sink",
snake: `
kind = "service-router"
name = "main"
routes = [
{
match {
http {
path_exact = "/foo"
header = [
{
name = "debug1"
present = true
},
{
name = "debug2"
present = false
invert = true
},
{
name = "debug3"
exact = "1"
},
{
name = "debug4"
prefix = "aaa"
},
{
name = "debug5"
suffix = "bbb"
},
{
name = "debug6"
regex = "a.*z"
},
]
}
}
destination {
service = "carrot"
service_subset = "kale"
namespace = "leek"
prefix_rewrite = "/alternate"
request_timeout = "99s"
num_retries = 12345
retry_on_connect_failure = true
retry_on_status_codes = [401, 209]
}
},
{
match {
http {
path_prefix = "/foo"
methods = [ "GET", "DELETE" ]
query_param = [
{
name = "hack1"
present = true
},
{
name = "hack2"
exact = "1"
},
{
name = "hack3"
regex = "a.*z"
},
]
}
}
},
{
match {
http {
path_regex = "/foo"
}
}
},
]
`,
camel: `
Kind = "service-router"
Name = "main"
Routes = [
{
Match {
HTTP {
PathExact = "/foo"
Header = [
{
Name = "debug1"
Present = true
},
{
Name = "debug2"
Present = false
Invert = true
},
{
Name = "debug3"
Exact = "1"
},
{
Name = "debug4"
Prefix = "aaa"
},
{
Name = "debug5"
Suffix = "bbb"
},
{
Name = "debug6"
Regex = "a.*z"
},
]
}
}
Destination {
Service = "carrot"
ServiceSubset = "kale"
Namespace = "leek"
PrefixRewrite = "/alternate"
RequestTimeout = "99s"
NumRetries = 12345
RetryOnConnectFailure = true
RetryOnStatusCodes = [401, 209]
}
},
{
Match {
HTTP {
PathPrefix = "/foo"
Methods = [ "GET", "DELETE" ]
QueryParam = [
{
Name = "hack1"
Present = true
},
{
Name = "hack2"
Exact = "1"
},
{
Name = "hack3"
Regex = "a.*z"
},
]
}
}
},
{
Match {
HTTP {
PathRegex = "/foo"
}
}
},
]
`,
snakeJSON: `
{
"kind": "service-router",
"name": "main",
"routes": [
{
"match": {
"http": {
"path_exact": "/foo",
"header": [
{
"name": "debug1",
"present": true
},
{
"name": "debug2",
"present": false,
"invert": true
},
{
"name": "debug3",
"exact": "1"
},
{
"name": "debug4",
"prefix": "aaa"
},
{
"name": "debug5",
"suffix": "bbb"
},
{
"name": "debug6",
"regex": "a.*z"
}
]
}
},
"destination": {
"service": "carrot",
"service_subset": "kale",
"namespace": "leek",
"prefix_rewrite": "/alternate",
"request_timeout": "99s",
"num_retries": 12345,
"retry_on_connect_failure": true,
"retry_on_status_codes": [
401,
209
]
}
},
{
"match": {
"http": {
"path_prefix": "/foo",
"methods": [
"GET",
"DELETE"
],
"query_param": [
{
"name": "hack1",
"present": true
},
{
"name": "hack2",
"exact": "1"
},
{
"name": "hack3",
"regex": "a.*z"
}
]
}
}
},
{
"match": {
"http": {
"path_regex": "/foo"
}
}
}
]
}
`,
camelJSON: `
{
"Kind": "service-router",
"Name": "main",
"Routes": [
{
"Match": {
"HTTP": {
"PathExact": "/foo",
"Header": [
{
"Name": "debug1",
"Present": true
},
{
"Name": "debug2",
"Present": false,
"Invert": true
},
{
"Name": "debug3",
"Exact": "1"
},
{
"Name": "debug4",
"Prefix": "aaa"
},
{
"Name": "debug5",
"Suffix": "bbb"
},
{
"Name": "debug6",
"Regex": "a.*z"
}
]
}
},
"Destination": {
"Service": "carrot",
"ServiceSubset": "kale",
"Namespace": "leek",
"PrefixRewrite": "/alternate",
"RequestTimeout": "99s",
"NumRetries": 12345,
"RetryOnConnectFailure": true,
"RetryOnStatusCodes": [
401,
209
]
}
},
{
"Match": {
"HTTP": {
"PathPrefix": "/foo",
"Methods": [
"GET",
"DELETE"
],
"QueryParam": [
{
"Name": "hack1",
"Present": true
},
{
"Name": "hack2",
"Exact": "1"
},
{
"Name": "hack3",
"Regex": "a.*z"
}
]
}
}
},
{
"Match": {
"HTTP": {
"PathRegex": "/foo"
}
}
}
]
}
`,
expect: &api.ServiceRouterConfigEntry{
Kind: "service-router",
Name: "main",
Routes: []api.ServiceRoute{
{
Match: &api.ServiceRouteMatch{
HTTP: &api.ServiceRouteHTTPMatch{
PathExact: "/foo",
Header: []api.ServiceRouteHTTPMatchHeader{
{
Name: "debug1",
Present: true,
},
{
Name: "debug2",
Present: false,
Invert: true,
},
{
Name: "debug3",
Exact: "1",
},
{
Name: "debug4",
Prefix: "aaa",
},
{
Name: "debug5",
Suffix: "bbb",
},
{
Name: "debug6",
Regex: "a.*z",
},
},
},
},
Destination: &api.ServiceRouteDestination{
Service: "carrot",
ServiceSubset: "kale",
Namespace: "leek",
PrefixRewrite: "/alternate",
RequestTimeout: 99 * time.Second,
NumRetries: 12345,
RetryOnConnectFailure: true,
RetryOnStatusCodes: []uint32{401, 209},
},
},
{
Match: &api.ServiceRouteMatch{
HTTP: &api.ServiceRouteHTTPMatch{
PathPrefix: "/foo",
Methods: []string{"GET", "DELETE"},
QueryParam: []api.ServiceRouteHTTPMatchQueryParam{
{
Name: "hack1",
Present: true,
},
{
Name: "hack2",
Exact: "1",
},
{
Name: "hack3",
Regex: "a.*z",
},
},
},
},
},
{
Match: &api.ServiceRouteMatch{
HTTP: &api.ServiceRouteHTTPMatch{
PathRegex: "/foo",
},
},
},
},
},
},
{
name: "service-splitter: kitchen sink",
snake: `
kind = "service-splitter"
name = "main"
splits = [
{
weight = 97.1
service_subset = "v1"
},
{
weight = 2
service_subset = "v2"
},
{
weight = 0.9
service = "other"
namespace = "alt"
},
]
`,
camel: `
Kind = "service-splitter"
Name = "main"
Splits = [
{
Weight = 97.1
ServiceSubset = "v1"
},
{
Weight = 2,
ServiceSubset = "v2"
},
{
Weight = 0.9
Service = "other"
Namespace = "alt"
},
]
`,
snakeJSON: `
{
"kind": "service-splitter",
"name": "main",
"splits": [
{
"weight": 97.1,
"service_subset": "v1"
},
{
"weight": 2,
"service_subset": "v2"
},
{
"weight": 0.9,
"service": "other",
"namespace": "alt"
}
]
}
`,
camelJSON: `
{
"Kind": "service-splitter",
"Name": "main",
"Splits": [
{
"Weight": 97.1,
"ServiceSubset": "v1"
},
{
"Weight": 2,
"ServiceSubset": "v2"
},
{
"Weight": 0.9,
"Service": "other",
"Namespace": "alt"
}
]
}
`,
expect: &api.ServiceSplitterConfigEntry{
Kind: api.ServiceSplitter,
Name: "main",
Splits: []api.ServiceSplit{
{
Weight: 97.1,
ServiceSubset: "v1",
},
{
Weight: 2,
ServiceSubset: "v2",
},
{
Weight: 0.9,
Service: "other",
Namespace: "alt",
},
},
},
},
{
name: "service-resolver: subsets with failover",
snake: `
kind = "service-resolver"
name = "main"
default_subset = "v1"
connect_timeout = "15s"
subsets = {
"v1" = {
filter = "Service.Meta.version == v1"
},
"v2" = {
filter = "Service.Meta.version == v2"
only_passing = true
},
}
failover = {
"v2" = {
service = "failcopy"
service_subset = "sure"
namespace = "neighbor"
datacenters = ["dc5", "dc14"]
},
"*" = {
datacenters = ["dc7"]
}
}`,
camel: `
Kind = "service-resolver"
Name = "main"
DefaultSubset = "v1"
ConnectTimeout = "15s"
Subsets = {
"v1" = {
Filter = "Service.Meta.version == v1"
},
"v2" = {
Filter = "Service.Meta.version == v2"
OnlyPassing = true
},
}
Failover = {
"v2" = {
Service = "failcopy"
ServiceSubset = "sure"
Namespace = "neighbor"
Datacenters = ["dc5", "dc14"]
},
"*" = {
Datacenters = ["dc7"]
}
}`,
snakeJSON: `
{
"kind": "service-resolver",
"name": "main",
"default_subset": "v1",
"connect_timeout": "15s",
"subsets": {
"v1": {
"filter": "Service.Meta.version == v1"
},
"v2": {
"filter": "Service.Meta.version == v2",
"only_passing": true
}
},
"failover": {
"v2": {
"service": "failcopy",
"service_subset": "sure",
"namespace": "neighbor",
"datacenters": [
"dc5",
"dc14"
]
},
"*": {
"datacenters": [
"dc7"
]
}
}
}
`,
camelJSON: `
{
"Kind": "service-resolver",
"Name": "main",
"DefaultSubset": "v1",
"ConnectTimeout": "15s",
"Subsets": {
"v1": {
"Filter": "Service.Meta.version == v1"
},
"v2": {
"Filter": "Service.Meta.version == v2",
"OnlyPassing": true
}
},
"Failover": {
"v2": {
"Service": "failcopy",
"ServiceSubset": "sure",
"Namespace": "neighbor",
"Datacenters": [
"dc5",
"dc14"
]
},
"*": {
"Datacenters": [
"dc7"
]
}
}
}
`,
expect: &api.ServiceResolverConfigEntry{
Kind: "service-resolver",
Name: "main",
DefaultSubset: "v1",
ConnectTimeout: 15 * time.Second,
Subsets: map[string]api.ServiceResolverSubset{
"v1": {
Filter: "Service.Meta.version == v1",
},
"v2": {
Filter: "Service.Meta.version == v2",
OnlyPassing: true,
},
},
Failover: map[string]api.ServiceResolverFailover{
"v2": {
Service: "failcopy",
ServiceSubset: "sure",
Namespace: "neighbor",
Datacenters: []string{"dc5", "dc14"},
},
"*": {
Datacenters: []string{"dc7"},
},
},
},
},
{
name: "service-resolver: redirect",
snake: `
kind = "service-resolver"
name = "main"
redirect {
service = "other"
service_subset = "backup"
namespace = "alt"
datacenter = "dc9"
}
`,
camel: `
Kind = "service-resolver"
Name = "main"
Redirect {
Service = "other"
ServiceSubset = "backup"
Namespace = "alt"
Datacenter = "dc9"
}
`,
snakeJSON: `
{
"kind": "service-resolver",
"name": "main",
"redirect": {
"service": "other",
"service_subset": "backup",
"namespace": "alt",
"datacenter": "dc9"
}
}
`,
camelJSON: `
{
"Kind": "service-resolver",
"Name": "main",
"Redirect": {
"Service": "other",
"ServiceSubset": "backup",
"Namespace": "alt",
"Datacenter": "dc9"
}
}
`,
expect: &api.ServiceResolverConfigEntry{
Kind: "service-resolver",
Name: "main",
Redirect: &api.ServiceResolverRedirect{
Service: "other",
ServiceSubset: "backup",
Namespace: "alt",
Datacenter: "dc9",
},
},
},
{
name: "service-resolver: default",
snake: `
kind = "service-resolver"
name = "main"
`,
camel: `
Kind = "service-resolver"
Name = "main"
`,
snakeJSON: `
{
"kind": "service-resolver",
"name": "main"
}
`,
camelJSON: `
{
"Kind": "service-resolver",
"Name": "main"
}
`,
expect: &api.ServiceResolverConfigEntry{
Kind: "service-resolver",
Name: "main",
},
},
{
name: "expose paths: kitchen sink proxy defaults",
snake: `
kind = "proxy-defaults"
name = "global"
expose = {
checks = true
paths = [
{
local_path_port = 8080
listener_port = 21500
path = "/healthz"
protocol = "http2"
}
]
}`,
camel: `
Kind = "proxy-defaults"
Name = "global"
Expose = {
Checks = true
Paths = [
{
LocalPathPort = 8080
ListenerPort = 21500
Path = "/healthz"
Protocol = "http2"
}
]
}`,
snakeJSON: `
{
"kind": "proxy-defaults",
"name": "global",
"expose": {
"checks": true,
"paths": [
{
"local_path_port": 8080,
"listener_port": 21500,
"path": "/healthz",
"protocol": "http2"
}
]
}
}
`,
camelJSON: `
{
"Kind": "proxy-defaults",
"Name": "global",
"Expose": {
"Checks": true,
"Paths": [
{
"LocalPathPort": 8080,
"ListenerPort": 21500,
"Path": "/healthz",
"Protocol": "http2"
}
]
}
}
`,
expect: &api.ProxyConfigEntry{
Kind: "proxy-defaults",
Name: "global",
Expose: api.ExposeConfig{
Checks: true,
Paths: []api.ExposePath{
{
ListenerPort: 21500,
Path: "/healthz",
LocalPathPort: 8080,
Protocol: "http2",
},
},
},
},
},
{
name: "ingress-gateway: kitchen sink",
snake: `
kind = "ingress-gateway"
name = "ingress-web"
listeners = [
{
port = 8080
protocol = "http"
services = [
{
name = "web"
service_subset = "v1"
},
{
name = "db"
namespace = "foo"
}
]
}
]
`,
camel: `
Kind = "ingress-gateway"
Name = "ingress-web"
Listeners = [
{
Port = 8080
Protocol = "http"
Services = [
{
Name = "web"
ServiceSubset = "v1"
},
{
Name = "db"
Namespace = "foo"
}
]
}
]
`,
snakeJSON: `
{
"kind": "ingress-gateway",
"name": "ingress-web",
"listeners": [
{
"port": 8080,
"protocol": "http",
"services": [
{
"name": "web",
"service_subset": "v1"
},
{
"name": "db",
"namespace": "foo"
}
]
}
]
}
`,
camelJSON: `
{
"Kind": "ingress-gateway",
"Name": "ingress-web",
"Listeners": [
{
"Port": 8080,
"Protocol": "http",
"Services": [
{
"Name": "web",
"ServiceSubset": "v1"
},
{
"Name": "db",
"Namespace": "foo"
}
]
}
]
}
`,
expect: &api.IngressGatewayConfigEntry{
Kind: "ingress-gateway",
Name: "ingress-web",
Listeners: []api.IngressListener{
{
Port: 8080,
Protocol: "http",
Services: []api.IngressService{
{
Name: "web",
ServiceSubset: "v1",
},
{
Name: "db",
Namespace: "foo",
},
},
},
},
},
},
} {
tc := tc
testbody := func(t *testing.T, body string, expect api.ConfigEntry) {
t.Helper()
got, err := parseConfigEntry(body)
if tc.expectErr != "" {
require.Nil(t, got)
require.Error(t, err)
requireContainsLower(t, err.Error(), tc.expectErr)
} else {
require.NoError(t, err)
require.Equal(t, expect, got)
}
}
t.Run(tc.name+" (hcl snake case)", func(t *testing.T) {
testbody(t, tc.snake, tc.expect)
})
t.Run(tc.name+" (hcl camel case)", func(t *testing.T) {
testbody(t, tc.camel, tc.expect)
})
if tc.snakeJSON != "" {
t.Run(tc.name+" (json snake case)", func(t *testing.T) {
if tc.expectJSON != nil {
testbody(t, tc.snakeJSON, tc.expectJSON)
} else {
testbody(t, tc.snakeJSON, tc.expect)
}
})
}
if tc.camelJSON != "" {
t.Run(tc.name+" (json camel case)", func(t *testing.T) {
if tc.expectJSON != nil {
testbody(t, tc.camelJSON, tc.expectJSON)
} else {
testbody(t, tc.camelJSON, tc.expect)
}
})
}
}
}
func requireContainsLower(t *testing.T, haystack, needle string) {
t.Helper()
require.Contains(t, strings.ToLower(haystack), strings.ToLower(needle))
}