a0774f4f1a
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
396 lines
12 KiB
Go
396 lines
12 KiB
Go
package alertsource
|
|
|
|
import (
|
|
"encoding/json"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestIsIdentifier(t *testing.T) {
|
|
cases := []struct {
|
|
s string
|
|
want bool
|
|
}{
|
|
{"api_token", true},
|
|
{"a", true},
|
|
{"a1", true},
|
|
{"", false},
|
|
{"1abc", true},
|
|
{"Abc", true},
|
|
{"has-dash", false},
|
|
{"has space", false},
|
|
{"_leading", true},
|
|
{"trailing_", true},
|
|
}
|
|
for _, c := range cases {
|
|
if got := isIdentifier(c.s); got != c.want {
|
|
t.Errorf("isIdentifier(%q) = %v, want %v", c.s, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestFieldSpec_Validate_ValidSpecHasNoErrors(t *testing.T) {
|
|
spec := NewFieldSpec().
|
|
Field(NewStringField("api_token").Secret()).
|
|
Field(NewIntField("poll_interval").Default(30)).
|
|
Field(NewBoolField("verify_tls").Default(true))
|
|
if err := spec.Validate(); err != nil {
|
|
t.Fatalf("expected no error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestFieldSpec_Validate_NilField(t *testing.T) {
|
|
spec := NewFieldSpec().Field(nil)
|
|
err := assertSpecError(t, spec.Validate())
|
|
assertHasCode(t, err, ErrCodeNilField)
|
|
}
|
|
|
|
func TestFieldSpec_Validate_EmptyName(t *testing.T) {
|
|
spec := NewFieldSpec().Field(NewStringField(""))
|
|
err := assertSpecError(t, spec.Validate())
|
|
assertHasCode(t, err, ErrCodeEmptyFieldName)
|
|
}
|
|
|
|
func TestFieldSpec_Validate_InvalidName(t *testing.T) {
|
|
spec := NewFieldSpec().Field(NewStringField("Bad-Name"))
|
|
err := assertSpecError(t, spec.Validate())
|
|
assertHasCode(t, err, ErrCodeInvalidFieldName)
|
|
}
|
|
|
|
func TestFieldSpec_Validate_DuplicateName(t *testing.T) {
|
|
spec := NewFieldSpec().
|
|
Field(NewStringField("region")).
|
|
Field(NewIntField("region"))
|
|
err := assertSpecError(t, spec.Validate())
|
|
assertHasCode(t, err, ErrCodeDuplicateField)
|
|
}
|
|
|
|
func TestFieldSpec_Validate_SecretWithDefaultOrExample(t *testing.T) {
|
|
spec := NewFieldSpec().
|
|
Field(NewStringField("token").Secret().Default("literal")).
|
|
Field(NewStringField("other").Secret().Example("literal"))
|
|
err := assertSpecError(t, spec.Validate())
|
|
assertHasCode(t, err, ErrCodeSecretHasLiteral)
|
|
}
|
|
|
|
// TestFieldSpec_Validate_SecretWithDefault_ReversedModifierOrder is R5-13:
|
|
// D5 records the defect "by the modifier", which is order-dependent
|
|
// language, but Validate() checks the field's final state, so .Default(x)
|
|
// before .Secret() must be flagged exactly like .Secret() before
|
|
// .Default(x).
|
|
func TestFieldSpec_Validate_SecretWithDefault_ReversedModifierOrder(t *testing.T) {
|
|
spec := NewFieldSpec().Field(NewStringField("token").Default("literal").Secret())
|
|
err := assertSpecError(t, spec.Validate())
|
|
assertHasCode(t, err, ErrCodeSecretHasLiteral)
|
|
}
|
|
|
|
func TestFieldSpec_Validate_SecretNonString(t *testing.T) {
|
|
spec := NewFieldSpec().Field(NewIntField("bad").Secret())
|
|
err := assertSpecError(t, spec.Validate())
|
|
assertHasCode(t, err, ErrCodeSecretNonStringKind)
|
|
}
|
|
|
|
func TestFieldSpec_Validate_DefaultKindMismatch(t *testing.T) {
|
|
spec := NewFieldSpec().Field(NewIntField("bad").Default("not an int"))
|
|
err := assertSpecError(t, spec.Validate())
|
|
assertHasCode(t, err, ErrCodeDefaultKindMismatch)
|
|
}
|
|
|
|
func TestFieldSpec_Validate_ExampleKindMismatch(t *testing.T) {
|
|
spec := NewFieldSpec().Field(NewBoolField("bad").Example("not a bool"))
|
|
err := assertSpecError(t, spec.Validate())
|
|
assertHasCode(t, err, ErrCodeExampleKindMismatch)
|
|
}
|
|
|
|
func TestFieldSpec_Validate_CollectsAllErrors(t *testing.T) {
|
|
spec := NewFieldSpec().
|
|
Field(nil).
|
|
Field(NewStringField("")).
|
|
Field(NewStringField("dup")).
|
|
Field(NewIntField("dup"))
|
|
verr, ok := spec.Validate().(*SpecError)
|
|
if !ok {
|
|
t.Fatalf("expected *SpecError, got %T", spec.Validate())
|
|
}
|
|
if len(verr.Errors) < 3 {
|
|
t.Fatalf("expected multiple collected errors, got %d: %+v", len(verr.Errors), verr.Errors)
|
|
}
|
|
}
|
|
|
|
// TestFieldSpec_Validate_DuplicateField_CardinalityAndOrdinal is A7's pinned
|
|
// rule: three declarations of the same name yield exactly two DuplicateField
|
|
// errors (never one, never three), the first declaration is never flagged,
|
|
// and each flagged occurrence's Message carries its own 1-based declaration
|
|
// ordinal so the two entries — which tie on both Field and Code — still sort
|
|
// deterministically under D10's third key.
|
|
func TestFieldSpec_Validate_DuplicateField_CardinalityAndOrdinal(t *testing.T) {
|
|
spec := NewFieldSpec().
|
|
Field(NewStringField("a")).
|
|
Field(NewStringField("a")).
|
|
Field(NewStringField("a"))
|
|
serr := assertSpecError(t, spec.Validate())
|
|
|
|
var dups []FieldError
|
|
for _, e := range serr.Errors {
|
|
if e.Code == ErrCodeDuplicateField {
|
|
dups = append(dups, e)
|
|
}
|
|
}
|
|
if len(dups) != 2 {
|
|
t.Fatalf("expected exactly 2 DuplicateField errors for 3 declarations, got %d: %+v", len(dups), dups)
|
|
}
|
|
for _, d := range dups {
|
|
if d.Field != "a" {
|
|
t.Fatalf("DuplicateField.Field must be the field name, got %q", d.Field)
|
|
}
|
|
}
|
|
if dups[0].Message == dups[1].Message {
|
|
t.Fatalf("expected distinct ordinals in Message to break the Field+Code tie, got identical: %q", dups[0].Message)
|
|
}
|
|
}
|
|
|
|
func TestFieldSpec_Schema_InvalidSpecReturnsError(t *testing.T) {
|
|
spec := NewFieldSpec().Field(nil)
|
|
if _, err := spec.Schema(); err == nil {
|
|
t.Fatal("expected error rendering schema for an invalid spec")
|
|
}
|
|
}
|
|
|
|
func TestFieldSpec_Schema_ShapeAndDeterminism(t *testing.T) {
|
|
spec := NewFieldSpec().
|
|
Field(NewStringField("api_token").Secret().Description("API token")).
|
|
Field(NewIntField("poll_interval").Default(30).Example(60)).
|
|
Field(NewDurationField("timeout").Default(5 * time.Second)).
|
|
Field(NewBoolField("verify_tls").Default(true).Advanced())
|
|
|
|
b1, err := spec.Schema()
|
|
if err != nil {
|
|
t.Fatalf("Schema() error: %v", err)
|
|
}
|
|
b2, err := spec.Schema()
|
|
if err != nil {
|
|
t.Fatalf("Schema() error on second call: %v", err)
|
|
}
|
|
if string(b1) != string(b2) {
|
|
t.Fatalf("Schema() is not deterministic across calls:\n%s\nvs\n%s", b1, b2)
|
|
}
|
|
|
|
var doc map[string]any
|
|
if err := json.Unmarshal(b1, &doc); err != nil {
|
|
t.Fatalf("Schema() did not produce valid JSON: %v", err)
|
|
}
|
|
if doc["$schema"] != "http://json-schema.org/draft-07/schema#" {
|
|
t.Fatalf("unexpected $schema: %v", doc["$schema"])
|
|
}
|
|
if doc["type"] != "object" {
|
|
t.Fatalf("unexpected type: %v", doc["type"])
|
|
}
|
|
if doc["x-kandev-required-mode"] != "create" {
|
|
t.Fatalf("unexpected x-kandev-required-mode: %v", doc["x-kandev-required-mode"])
|
|
}
|
|
if doc["additionalProperties"] != false {
|
|
t.Fatalf("unexpected additionalProperties: %v", doc["additionalProperties"])
|
|
}
|
|
order, ok := doc["x-kandev-field-order"].([]any)
|
|
if !ok || len(order) != 4 || order[0] != "api_token" {
|
|
t.Fatalf("unexpected x-kandev-field-order: %v", doc["x-kandev-field-order"])
|
|
}
|
|
required, ok := doc["required"].([]any)
|
|
if !ok || len(required) != 1 || required[0] != "api_token" {
|
|
t.Fatalf("unexpected required (only api_token has no Default): %v", doc["required"])
|
|
}
|
|
|
|
props, ok := doc["properties"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("properties is not an object: %v", doc["properties"])
|
|
}
|
|
tokenProp, ok := props["api_token"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("missing api_token property")
|
|
}
|
|
if tokenProp["x-kandev-secret"] != true {
|
|
t.Fatalf("expected api_token to be marked x-kandev-secret: %v", tokenProp)
|
|
}
|
|
if tokenProp["writeOnly"] != true {
|
|
t.Fatalf("expected api_token to be marked writeOnly: %v", tokenProp)
|
|
}
|
|
if _, hasDefault := tokenProp["default"]; hasDefault {
|
|
t.Fatalf("secret field must never emit a default in the schema: %v", tokenProp)
|
|
}
|
|
if _, hasExamples := tokenProp["examples"]; hasExamples {
|
|
t.Fatalf("secret field must never emit examples in the schema: %v", tokenProp)
|
|
}
|
|
|
|
timeoutProp, ok := props["timeout"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("missing timeout property")
|
|
}
|
|
if timeoutProp["type"] != "string" {
|
|
t.Fatalf("duration field must render JSON type string, got %v", timeoutProp["type"])
|
|
}
|
|
if timeoutProp["x-kandev-field-kind"] != "duration" {
|
|
t.Fatalf("unexpected x-kandev-field-kind: %v", timeoutProp["x-kandev-field-kind"])
|
|
}
|
|
if timeoutProp["default"] != "5s" {
|
|
t.Fatalf("duration default must render as Go-syntax string, got %v", timeoutProp["default"])
|
|
}
|
|
|
|
verifyProp, ok := props["verify_tls"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("missing verify_tls property")
|
|
}
|
|
if verifyProp["x-kandev-advanced"] != true {
|
|
t.Fatalf("expected verify_tls to be marked x-kandev-advanced: %v", verifyProp)
|
|
}
|
|
|
|
intervalProp, ok := props["poll_interval"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("missing poll_interval property")
|
|
}
|
|
if intervalProp["type"] != "integer" {
|
|
t.Fatalf("int field must render JSON type integer, got %v", intervalProp["type"])
|
|
}
|
|
if intervalProp["x-kandev-field-kind"] != "int" {
|
|
t.Fatalf("unexpected x-kandev-field-kind for int: %v", intervalProp["x-kandev-field-kind"])
|
|
}
|
|
examples, ok := intervalProp["examples"].([]any)
|
|
if !ok || len(examples) != 1 || examples[0] != float64(60) {
|
|
t.Fatalf("expected examples to be the Draft 7 array form [60], got %v", intervalProp["examples"])
|
|
}
|
|
}
|
|
|
|
// TestFieldSpec_Schema_EmptySpecStillWellFormed is D9's "the empty spec still
|
|
// emits a well-formed root" clause: every root key except the
|
|
// omission-conditional "required" is present even with zero declared fields.
|
|
func TestFieldSpec_Schema_EmptySpecStillWellFormed(t *testing.T) {
|
|
spec := NewFieldSpec()
|
|
b, err := spec.Schema()
|
|
if err != nil {
|
|
t.Fatalf("Schema() error on empty spec: %v", err)
|
|
}
|
|
var doc map[string]any
|
|
if err := json.Unmarshal(b, &doc); err != nil {
|
|
t.Fatalf("Schema() did not produce valid JSON: %v", err)
|
|
}
|
|
if _, present := doc["required"]; present {
|
|
t.Fatalf("required must be omitted entirely when empty, got %v", doc["required"])
|
|
}
|
|
order, ok := doc["x-kandev-field-order"].([]any)
|
|
if !ok || len(order) != 0 {
|
|
t.Fatalf("x-kandev-field-order must be present and empty, got %v", doc["x-kandev-field-order"])
|
|
}
|
|
props, ok := doc["properties"].(map[string]any)
|
|
if !ok || len(props) != 0 {
|
|
t.Fatalf("properties must be present and empty, got %v", doc["properties"])
|
|
}
|
|
if doc["x-kandev-required-mode"] != "create" {
|
|
t.Fatalf("x-kandev-required-mode must still be present, got %v", doc["x-kandev-required-mode"])
|
|
}
|
|
if doc["additionalProperties"] != false {
|
|
t.Fatalf("additionalProperties must still be present, got %v", doc["additionalProperties"])
|
|
}
|
|
}
|
|
|
|
func TestCoerceValue_String(t *testing.T) {
|
|
v, ok := coerceValue(FieldKindString, "hello")
|
|
if !ok || v != "hello" {
|
|
t.Fatalf("coerceValue(string) = (%v, %v)", v, ok)
|
|
}
|
|
if _, ok := coerceValue(FieldKindString, 5); ok {
|
|
t.Fatal("expected int to be rejected for a string field")
|
|
}
|
|
}
|
|
|
|
func TestCoerceValue_Bool(t *testing.T) {
|
|
v, ok := coerceValue(FieldKindBool, true)
|
|
if !ok || v != true {
|
|
t.Fatalf("coerceValue(bool) = (%v, %v)", v, ok)
|
|
}
|
|
if _, ok := coerceValue(FieldKindBool, "true"); ok {
|
|
t.Fatal("expected string to be rejected for a bool field")
|
|
}
|
|
}
|
|
|
|
func TestCoerceValue_Int(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
in any
|
|
want int
|
|
ok bool
|
|
}{
|
|
{"go int", 42, 42, true},
|
|
{"json number", float64(42), 42, true},
|
|
{"non-integral float", 42.5, 0, false},
|
|
{"string", "42", 0, false},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
got, ok := coerceValue(FieldKindInt, c.in)
|
|
if ok != c.ok {
|
|
t.Fatalf("ok = %v, want %v", ok, c.ok)
|
|
}
|
|
if ok && got != c.want {
|
|
t.Fatalf("got %v, want %v", got, c.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCoerceValue_Duration(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
in any
|
|
want time.Duration
|
|
ok bool
|
|
}{
|
|
{"go duration", 5 * time.Second, 5 * time.Second, true},
|
|
{"go-syntax string", "5s", 5 * time.Second, true},
|
|
{"invalid string", "not a duration", 0, false},
|
|
{"bare number rejected", float64(5), 0, false},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
got, ok := coerceValue(FieldKindDuration, c.in)
|
|
if ok != c.ok {
|
|
t.Fatalf("ok = %v, want %v", ok, c.ok)
|
|
}
|
|
if ok && got != c.want {
|
|
t.Fatalf("got %v, want %v", got, c.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// assertValidationError fails the test if err is not a non-nil
|
|
// *ValidationError, and returns it for further inspection.
|
|
func assertValidationError(t *testing.T, err error) *ValidationError {
|
|
t.Helper()
|
|
verr, ok := err.(*ValidationError)
|
|
if !ok || verr == nil {
|
|
t.Fatalf("expected *ValidationError, got %#v", err)
|
|
}
|
|
return verr
|
|
}
|
|
|
|
// assertSpecError fails the test if err is not a non-nil *SpecError, and
|
|
// returns it for further inspection.
|
|
func assertSpecError(t *testing.T, err error) *SpecError {
|
|
t.Helper()
|
|
serr, ok := err.(*SpecError)
|
|
if !ok || serr == nil {
|
|
t.Fatalf("expected *SpecError, got %#v", err)
|
|
}
|
|
return serr
|
|
}
|
|
|
|
// assertHasCode fails the test if none of serr's errors carry code.
|
|
func assertHasCode(t *testing.T, serr *SpecError, code ErrorCode) {
|
|
t.Helper()
|
|
for _, e := range serr.Errors {
|
|
if e.Code == code {
|
|
return
|
|
}
|
|
}
|
|
t.Fatalf("expected an error with code %q, got %+v", code, serr.Errors)
|
|
}
|