package cabana import ( "context" "encoding/json" "errors" "fmt" "strings" "testing" "git.golem15.com/golem15/summercms/modules/pact" ) // TestDependentDropdownCompile pins D-08's compile rules for dependsOn: a // dropdown only, no static options, a sibling of the same form (or repeater // row) with a scalar value, never itself, and a controller that implements // pact.DependentOptionsProvider. func TestDependentDropdownCompile(t *testing.T) { t.Run("a valid dependent dropdown compiles", func(t *testing.T) { cc := ucMustCompile(t, ucFields+ucRepeater(""), nil) if ref := ucField(t, cc, "reference"); ref.DependsOn != "type" || len(ref.Options) != 0 { t.Fatalf("reference = %+v", ref) } if !usesDependsOn(cc.Form.Fields) { t.Fatal("usesDependsOn = false") } }) base := "fields:\n title:\n type: text\n type:\n type: dropdown\n options: typeOptions\n" for _, tc := range []struct { name, fields string ctl pact.AdminController want string }{ {"on a text field", base + " reference:\n type: text\n dependsOn: type\n", nil, "dependsOn is only valid on type: dropdown"}, {"on a textarea", base + " reference:\n type: textarea\n dependsOn: type\n", nil, "dependsOn is only valid on type: dropdown"}, {"with static options", base + " reference:\n type: dropdown\n dependsOn: type\n options: typeOptions\n", nil, "a dropdown with dependsOn takes no options"}, {"not a field name", base + " reference:\n type: dropdown\n dependsOn: \"a b\"\n", nil, `dependsOn "a b" is not a field name`}, {"names itself", base + " reference:\n type: dropdown\n dependsOn: reference\n", nil, "field reference: dependsOn names the field itself"}, {"unknown sibling", base + " reference:\n type: dropdown\n dependsOn: kind\n", nil, "field reference: dependsOn kind is not a field of this form"}, {"sibling without a scalar value", base + ucRepeater("") + " pick:\n type: dropdown\n dependsOn: rows\n", nil, "field pick: dependsOn rows must be a field with a scalar value, not type repeater"}, {"no provider", ucFields, ucPlainLinks{}, "field reference: dependsOn needs controller acme.unit.links to implement pact.DependentOptionsProvider"}, {"no provider for a nested dropdown", base + ucRepeater(""), ucPlainLinks{}, "field rows.reference: dependsOn needs controller acme.unit.links to implement pact.DependentOptionsProvider"}, {"nested unknown sibling", base + " rows:\n type: repeater\n form:\n fields:\n reference:\n type: dropdown\n dependsOn: title\n", nil, "field rows.reference: dependsOn title is not a field of the same repeater row"}, } { t.Run(tc.name, func(t *testing.T) { _, err := ucCompile(tc.fields, tc.ctl) wantErr(t, err, tc.want) }) } t.Run("a controller without dependsOn needs no provider", func(t *testing.T) { if _, err := ucCompile(base, ucPlainLinks{}); err != nil { t.Fatal(err) } }) t.Run("dependent fields are found by path", func(t *testing.T) { cc := ucMustCompile(t, ucFields+ucRepeater(""), nil) for _, tc := range []struct { path, want string ok bool }{ {"reference", "reference", true}, {"rows.reference", "rows.reference", true}, {"title", "", false}, {"type", "", false}, {"rows.title", "", false}, {"rows.missing", "", false}, {"rows", "", false}, {"title.reference", "", false}, {"missing", "", false}, } { field, ok := dependentField(cc, tc.path) if ok != tc.ok || (ok && (field.Name != tc.want || field.DependsOn != "type")) { t.Fatalf("dependentField(%q) = %+v, %v", tc.path, field, ok) } } if _, ok := dependentField(nil, "reference"); ok { t.Fatal("nil controller answered") } }) } // TestDependentValues pins the Docker-free half of the D-08 save recheck: // sibling values are normalized the same way for the options route and a // save, null is always accepted, a value outside the options is a 422 on the // field (field.index.nested in a row), a type that takes no reference stores // null, the stored sibling or value is used when only the other is written, // and the provider is asked once per path and value. func TestDependentValues(t *testing.T) { for _, tc := range []struct { in any want any }{ {nil, nil}, {"", nil}, {"x", "x"}, {true, "true"}, {false, "false"}, {json.Number("12"), "12"}, {float64(2), "2"}, {2.5, "2.5"}, {3, "3"}, {int64(4), "4"}, {uint(5), "5"}, {[]any{1}, "[1]"}, } { if got := dependentValue(tc.in); got != tc.want { t.Fatalf("dependentValue(%#v) = %#v, want %#v", tc.in, got, tc.want) } } set := pact.DependentOptionSet{Applies: true, Options: []pact.Option{{Value: "1"}, {Value: "2"}}} for _, tc := range []struct { value any ok bool }{{nil, true}, {"", true}, {"1", true}, {float64(2), true}, {json.Number("2"), true}, {"3", false}, {"01", false}, {true, false}} { if got := amongOptions(set, tc.value); got != tc.ok { t.Fatalf("amongOptions(%#v) = %v", tc.value, got) } } page, list := "acme-page", "acme-list" stale := "99" two := "2" check := func(t *testing.T, target *ucLink, projected map[string]any) (map[string]any, []string, error) { t.Helper() var calls []string cc := ucMustCompile(t, ucFields+ucRepeater(""), ucLinks{calls: &calls}) err := checkDependentValues(context.Background(), cc, nil, target, projected, "update") var invalid *ValidationError if errors.As(err, &invalid) { return invalid.Details, calls, nil } return nil, calls, err } t.Run("top level", func(t *testing.T) { for _, tc := range []struct { name string target ucLink projected map[string]any wantInvalid bool wantReference any wantCalls string }{ {"a value among the options", ucLink{}, map[string]any{"type": page, "reference": "2"}, false, "2", "[reference=map[type:acme-page]]"}, {"a number value is compared as text", ucLink{}, map[string]any{"type": page, "reference": float64(1)}, false, float64(1), "[reference=map[type:acme-page]]"}, {"null is accepted", ucLink{}, map[string]any{"type": page, "reference": nil}, false, nil, "[reference=map[type:acme-page]]"}, {"a value outside the options", ucLink{}, map[string]any{"type": page, "reference": "99"}, true, "99", "[reference=map[type:acme-page]]"}, {"a type without references stores null", ucLink{}, map[string]any{"type": list, "reference": "1"}, false, nil, "[reference=map[type:acme-list]]"}, {"an empty type stores null", ucLink{}, map[string]any{"type": "", "reference": "1"}, false, nil, "[reference=map[type:]]"}, {"only the value: the stored sibling decides", ucLink{Type: &page}, map[string]any{"reference": "99"}, true, "99", "[reference=map[type:acme-page]]"}, {"only the value, stored sibling accepts it", ucLink{Type: &page}, map[string]any{"reference": "1"}, false, "1", "[reference=map[type:acme-page]]"}, {"only the sibling: the stored value is checked", ucLink{Reference: &stale}, map[string]any{"type": page}, true, nil, "[reference=map[type:acme-page]]"}, {"only the sibling, stored value still valid", ucLink{Reference: &two}, map[string]any{"type": page}, false, nil, "[reference=map[type:acme-page]]"}, {"only the sibling to a type without references clears the value", ucLink{Reference: &two}, map[string]any{"type": list}, false, nil, "[reference=map[type:acme-list]]"}, {"neither written asks nothing", ucLink{Type: &page, Reference: &stale}, map[string]any{"title": "x"}, false, nil, "[]"}, } { t.Run(tc.name, func(t *testing.T) { target := tc.target details, calls, err := check(t, &target, tc.projected) if err != nil { t.Fatal(err) } if (details != nil) != tc.wantInvalid { t.Fatalf("details = %v, want invalid %v", details, tc.wantInvalid) } if tc.wantInvalid { if msgs, _ := details["reference"].([]string); len(msgs) != 1 || msgs[0] != msgDependentInvalid || len(details) != 1 { t.Fatalf("details = %v", details) } } if got, present := tc.projected["reference"]; present || tc.wantReference != nil { if fmt.Sprint(got) != fmt.Sprint(tc.wantReference) { t.Fatalf("projected reference = %#v, want %#v", got, tc.wantReference) } } if strings.Contains(tc.name, "stores null") || strings.Contains(tc.name, "clears the value") { if v, present := tc.projected["reference"]; !present || v != nil { t.Fatalf("reference not written as null: %#v (present %v)", v, present) } } if fmt.Sprint(calls) != tc.wantCalls { t.Fatalf("provider calls = %v, want %s", calls, tc.wantCalls) } }) } }) t.Run("repeater rows", func(t *testing.T) { projected := map[string]any{"rows": []any{ map[string]any{"title": "a", "type": page, "reference": "1"}, map[string]any{"title": "b", "type": page, "reference": "99"}, map[string]any{"title": "c", "type": list, "reference": "1"}, map[string]any{"title": "d", "type": page, "reference": "2"}, "not a row", }} details, calls, err := check(t, &ucLink{}, projected) if err != nil { t.Fatal(err) } if msgs, _ := details["rows.1.reference"].([]string); len(msgs) != 1 || msgs[0] != msgDependentInvalid || len(details) != 1 { t.Fatalf("details = %v", details) } rows := projected["rows"].([]any) if rows[2].(map[string]any)["reference"] != nil || rows[0].(map[string]any)["reference"] != "1" { t.Fatalf("rows = %v", rows) } // One call per distinct path and value: the second acme-page row // and the fourth reuse the first answer. if fmt.Sprint(calls) != "[rows.reference=map[type:acme-page] rows.reference=map[type:acme-list]]" { t.Fatalf("provider calls = %v", calls) } }) t.Run("a provider error is returned, not a 422", func(t *testing.T) { broken := "acme-broken" _, _, err := check(t, &ucLink{}, map[string]any{"type": broken, "reference": "1"}) if err == nil || !strings.Contains(err.Error(), "references failed") { t.Fatalf("err = %v", err) } }) t.Run("a form without dependsOn asks nothing", func(t *testing.T) { cc := ucMustCompile(t, "fields:\n title:\n type: text\n", ucPlainLinks{}) if err := checkDependentValues(context.Background(), cc, nil, &ucLink{}, map[string]any{"title": "x"}, "update"); err != nil { t.Fatal(err) } }) }