Files

217 lines
7.8 KiB
Go
Raw Permalink Normal View History

package handlers
import (
"encoding/json"
"reflect"
"strings"
"testing"
pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1"
)
// TestAdminFormToJSONSchemaFields locks the bridge between the SDK proto
// AdminForm descriptor and the JSON the frontend SchemaForm consumes. Without
// this, the schema-driven request-connection form silently loses its
// dynamic dropdowns (dynamic_options), conditional visibility (show_when),
// validation, sections, and the MULTI_SELECT control mapping, which makes the
// new form non-functional (required fields can never be set, save stays
// disabled).
func TestAdminFormToJSONSchemaFields(t *testing.T) {
descriptor := &pluginv1.AdminFormDescriptor{
SubmitLabel: "Connect",
Fields: []*pluginv1.AdminFormField{
{
Key: "quality_profile_ids",
Label: "Quality Profiles",
Control: pluginv1.AdminFormControl_ADMIN_FORM_CONTROL_MULTI_SELECT,
DynamicOptions: true,
ExclusiveGroupField: "service_kind",
ShowWhen: []*pluginv1.AdminFormCondition{
{Field: "service_type", Equals: []string{"radarr", "sonarr"}},
},
Validation: &pluginv1.AdminFormValidation{
HasMin: true,
Min: 1,
HasMax: true,
Max: 10,
Pattern: "^[0-9]+$",
MinLength: 1,
MaxLength: 64,
},
},
},
Sections: []*pluginv1.AdminFormSection{
{
Key: "advanced",
Title: "Advanced",
Description: "Optional tuning",
Collapsible: true,
CollapsedDefault: true,
FieldKeys: []string{"quality_profile_ids"},
ShowWhen: []*pluginv1.AdminFormCondition{
{Field: "service_type", Equals: []string{"radarr"}},
},
},
},
}
form := adminFormToJSON(descriptor)
if form == nil {
t.Fatal("adminFormToJSON returned nil")
}
raw, err := json.Marshal(form)
if err != nil {
t.Fatalf("marshal admin form: %v", err)
}
var decoded struct {
Fields []struct {
Control string `json:"control"`
DynamicOptions bool `json:"dynamic_options"`
ShowWhen []struct {
Field string `json:"field"`
Equals []string `json:"equals"`
} `json:"show_when"`
Validation *struct {
HasMin bool `json:"has_min"`
Min float64 `json:"min"`
HasMax bool `json:"has_max"`
Max float64 `json:"max"`
Pattern string `json:"pattern"`
MinLength int32 `json:"min_length"`
MaxLength int32 `json:"max_length"`
} `json:"validation"`
ExclusiveGroupField string `json:"exclusive_group_field"`
} `json:"fields"`
Sections []struct {
Key string `json:"key"`
Title string `json:"title"`
Description string `json:"description"`
Collapsible bool `json:"collapsible"`
CollapsedDefault bool `json:"collapsed_default"`
FieldKeys []string `json:"field_keys"`
ShowWhen []struct {
Field string `json:"field"`
Equals []string `json:"equals"`
} `json:"show_when"`
} `json:"sections"`
}
if err := json.Unmarshal(raw, &decoded); err != nil {
t.Fatalf("unmarshal admin form: %v\njson: %s", err, raw)
}
if len(decoded.Fields) != 1 {
t.Fatalf("expected 1 field, got %d", len(decoded.Fields))
}
field := decoded.Fields[0]
// Control must serialize to the SHORT form the frontend switches on.
if field.Control != "MULTI_SELECT" {
t.Errorf("control = %q, want %q", field.Control, "MULTI_SELECT")
}
if !field.DynamicOptions {
t.Error("dynamic_options = false, want true")
}
if field.ExclusiveGroupField != "service_kind" {
t.Errorf("exclusive_group_field = %q, want %q", field.ExclusiveGroupField, "service_kind")
}
if len(field.ShowWhen) != 1 || field.ShowWhen[0].Field != "service_type" ||
len(field.ShowWhen[0].Equals) != 2 {
t.Errorf("field show_when not serialized correctly: %+v", field.ShowWhen)
}
if field.Validation == nil {
t.Fatal("validation not serialized")
}
if !field.Validation.HasMin || field.Validation.Min != 1 ||
!field.Validation.HasMax || field.Validation.Max != 10 ||
field.Validation.Pattern != "^[0-9]+$" ||
field.Validation.MinLength != 1 || field.Validation.MaxLength != 64 {
t.Errorf("validation not serialized correctly: %+v", field.Validation)
}
if len(decoded.Sections) != 1 {
t.Fatalf("expected 1 section, got %d", len(decoded.Sections))
}
section := decoded.Sections[0]
if section.Key != "advanced" || section.Title != "Advanced" ||
section.Description != "Optional tuning" || !section.Collapsible ||
!section.CollapsedDefault {
t.Errorf("section scalar fields not serialized correctly: %+v", section)
}
if len(section.FieldKeys) != 1 || section.FieldKeys[0] != "quality_profile_ids" {
t.Errorf("section field_keys not serialized correctly: %+v", section.FieldKeys)
}
if len(section.ShowWhen) != 1 || section.ShowWhen[0].Field != "service_type" ||
len(section.ShowWhen[0].Equals) != 1 {
t.Errorf("section show_when not serialized correctly: %+v", section.ShowWhen)
}
}
// adminFormDTOMappingRenames records intentional proto-field -> DTO-field name
// renames. The DTO field names currently mirror the proto field names 1:1, so
// this is empty; add an entry only when a DTO field is deliberately named
// differently from its proto source (the test then accepts the renamed target).
var adminFormDTOMappingRenames = map[string]string{}
// protoInternalFields are the protobuf-runtime bookkeeping fields every
// generated message struct carries. They are never serialized to the client
// DTO, so the completeness guard skips them.
var protoInternalFields = map[string]bool{
"state": true,
"sizeCache": true,
"unknownFields": true,
}
// TestAdminFormDTOCompleteness is a guard against the C1-class bug: the
// hand-written adminFormToJSON allowlist (pluginAdminFormFieldJSON /
// pluginAdminFormSectionJSON / pluginAdminFormJSON) silently dropping a NEW
// proto field added to AdminFormField / AdminFormDescriptor / AdminFormSection
// later. It reflects over each proto message's exported fields and asserts the
// corresponding JSON DTO struct has a field mapping to it (by identical Go name,
// or via an explicit rename in adminFormDTOMappingRenames). When the next proto
// field lands, this FAILS naming the un-mapped field, forcing the serializer +
// DTO to be updated in lockstep. (We deliberately do not switch the serializer
// to protojson — the short-form control-string MULTI_SELECT mapping makes that
// risky; this guard is the proportionate fix.)
func TestAdminFormDTOCompleteness(t *testing.T) {
cases := []struct {
name string
proto reflect.Type
dto reflect.Type
}{
{"AdminFormField", reflect.TypeOf(pluginv1.AdminFormField{}), reflect.TypeOf(pluginAdminFormFieldJSON{})},
{"AdminFormDescriptor", reflect.TypeOf(pluginv1.AdminFormDescriptor{}), reflect.TypeOf(pluginAdminFormJSON{})},
{"AdminFormSection", reflect.TypeOf(pluginv1.AdminFormSection{}), reflect.TypeOf(pluginAdminFormSectionJSON{})},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
dtoFields := map[string]bool{}
for i := 0; i < tc.dto.NumField(); i++ {
dtoFields[tc.dto.Field(i).Name] = true
}
for i := 0; i < tc.proto.NumField(); i++ {
pf := tc.proto.Field(i)
if !pf.IsExported() {
continue // unexported protobuf-internal state
}
if protoInternalFields[pf.Name] || strings.HasPrefix(pf.Name, "XXX_") {
continue
}
want := pf.Name
if mapped, ok := adminFormDTOMappingRenames[pf.Name]; ok {
want = mapped
}
if !dtoFields[want] {
t.Errorf("proto field %s.%s has no corresponding field %q in the JSON DTO %s; "+
"adminFormToJSON would silently drop it. Add the field to the DTO + serializer "+
"(or record an intentional rename in adminFormDTOMappingRenames).",
tc.name, pf.Name, want, tc.dto.Name())
}
}
})
}
}