217 lines
7.8 KiB
Go
217 lines
7.8 KiB
Go
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())
|
||
|
|
}
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|