From 2256f2721130b50baa9e9cd04a64f7e5464238f1 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Mon, 7 Sep 2026 15:42:26 +0200 Subject: [PATCH 1/5] Marshal resource types by value, not only by pointer A pointer-receiver MarshalJSON is satisfied by *T but not by T, and encoding/json reaches it only for an addressable value. json.Marshal(&x) uses the marshaler while json.Marshal(x) silently falls back to plain struct-field encoding -- two code paths for one type. They disagree on more than key order, because encoding/json knows nothing about ForceSendFields (tagged json:"-"): PurgeOnDelete=false, ForceSendFields=["PurgeOnDelete"] VALUE {"project_id":""} POINTER {"project_id":"","purge_on_delete":false} Give the 8 affected types a value receiver, and assert the invariant over every adapter surface. The existing round-trip tests cannot catch it: they build values with reflect.New, so they only ever marshal a pointer. Nothing marshals these by value today (the state path passes pointers throughout, and bundle config travels through libs/dyn), so this is a latent trap rather than live corruption -- no golden output moves. --- bundle/config/resources/postgres_branch.go | 2 +- bundle/config/resources/postgres_catalog.go | 2 +- bundle/config/resources/postgres_database.go | 2 +- bundle/config/resources/postgres_endpoint.go | 2 +- bundle/config/resources/postgres_project.go | 2 +- bundle/config/resources/postgres_role.go | 2 +- .../config/resources/postgres_synced_table.go | 2 +- bundle/config/resources/secret.go | 2 +- bundle/direct/dresources/serialize_test.go | 42 +++++++++++++++++++ 9 files changed, 50 insertions(+), 8 deletions(-) diff --git a/bundle/config/resources/postgres_branch.go b/bundle/config/resources/postgres_branch.go index d6dd538febb..2713c0cdd58 100644 --- a/bundle/config/resources/postgres_branch.go +++ b/bundle/config/resources/postgres_branch.go @@ -42,7 +42,7 @@ func (c *PostgresBranchConfig) UnmarshalJSON(b []byte) error { return marshal.Unmarshal(b, c) } -func (c *PostgresBranchConfig) MarshalJSON() ([]byte, error) { +func (c PostgresBranchConfig) MarshalJSON() ([]byte, error) { return marshal.Marshal(c) } diff --git a/bundle/config/resources/postgres_catalog.go b/bundle/config/resources/postgres_catalog.go index 8295d70deec..d1c788aece6 100644 --- a/bundle/config/resources/postgres_catalog.go +++ b/bundle/config/resources/postgres_catalog.go @@ -23,7 +23,7 @@ func (c *PostgresCatalogConfig) UnmarshalJSON(b []byte) error { return marshal.Unmarshal(b, c) } -func (c *PostgresCatalogConfig) MarshalJSON() ([]byte, error) { +func (c PostgresCatalogConfig) MarshalJSON() ([]byte, error) { return marshal.Marshal(c) } diff --git a/bundle/config/resources/postgres_database.go b/bundle/config/resources/postgres_database.go index ef9e013bdaa..ad16d720bb9 100644 --- a/bundle/config/resources/postgres_database.go +++ b/bundle/config/resources/postgres_database.go @@ -31,7 +31,7 @@ func (c *PostgresDatabaseConfig) UnmarshalJSON(b []byte) error { return marshal.Unmarshal(b, c) } -func (c *PostgresDatabaseConfig) MarshalJSON() ([]byte, error) { +func (c PostgresDatabaseConfig) MarshalJSON() ([]byte, error) { return marshal.Marshal(c) } diff --git a/bundle/config/resources/postgres_endpoint.go b/bundle/config/resources/postgres_endpoint.go index 3427a382bab..1ae81fdf8ad 100644 --- a/bundle/config/resources/postgres_endpoint.go +++ b/bundle/config/resources/postgres_endpoint.go @@ -30,7 +30,7 @@ func (c *PostgresEndpointConfig) UnmarshalJSON(b []byte) error { return marshal.Unmarshal(b, c) } -func (c *PostgresEndpointConfig) MarshalJSON() ([]byte, error) { +func (c PostgresEndpointConfig) MarshalJSON() ([]byte, error) { return marshal.Marshal(c) } diff --git a/bundle/config/resources/postgres_project.go b/bundle/config/resources/postgres_project.go index c8ffdb01142..1f0165fd9b7 100644 --- a/bundle/config/resources/postgres_project.go +++ b/bundle/config/resources/postgres_project.go @@ -34,7 +34,7 @@ func (c *PostgresProjectConfig) UnmarshalJSON(b []byte) error { return marshal.Unmarshal(b, c) } -func (c *PostgresProjectConfig) MarshalJSON() ([]byte, error) { +func (c PostgresProjectConfig) MarshalJSON() ([]byte, error) { return marshal.Marshal(c) } diff --git a/bundle/config/resources/postgres_role.go b/bundle/config/resources/postgres_role.go index 6f39cba6f1a..b45c6928557 100644 --- a/bundle/config/resources/postgres_role.go +++ b/bundle/config/resources/postgres_role.go @@ -32,7 +32,7 @@ func (c *PostgresRoleConfig) UnmarshalJSON(b []byte) error { return marshal.Unmarshal(b, c) } -func (c *PostgresRoleConfig) MarshalJSON() ([]byte, error) { +func (c PostgresRoleConfig) MarshalJSON() ([]byte, error) { return marshal.Marshal(c) } diff --git a/bundle/config/resources/postgres_synced_table.go b/bundle/config/resources/postgres_synced_table.go index bd7dcd1a172..e92f19c2ab4 100644 --- a/bundle/config/resources/postgres_synced_table.go +++ b/bundle/config/resources/postgres_synced_table.go @@ -25,7 +25,7 @@ func (c *PostgresSyncedTableConfig) UnmarshalJSON(b []byte) error { return marshal.Unmarshal(b, c) } -func (c *PostgresSyncedTableConfig) MarshalJSON() ([]byte, error) { +func (c PostgresSyncedTableConfig) MarshalJSON() ([]byte, error) { return marshal.Marshal(c) } diff --git a/bundle/config/resources/secret.go b/bundle/config/resources/secret.go index 445b56fa1d3..d7fe4ed1a71 100644 --- a/bundle/config/resources/secret.go +++ b/bundle/config/resources/secret.go @@ -25,7 +25,7 @@ func (s *Secret) UnmarshalJSON(b []byte) error { return marshal.Unmarshal(b, s) } -func (s *Secret) MarshalJSON() ([]byte, error) { +func (s Secret) MarshalJSON() ([]byte, error) { return marshal.Marshal(s) } diff --git a/bundle/direct/dresources/serialize_test.go b/bundle/direct/dresources/serialize_test.go index b7f253f24c9..e4eaf2cc81b 100644 --- a/bundle/direct/dresources/serialize_test.go +++ b/bundle/direct/dresources/serialize_test.go @@ -148,6 +148,48 @@ func TestRoundtripAllFieldsInputConfigType(t *testing.T) { testRoundtripAllFields(t, "InputConfigType", (*Adapter).InputConfigType, []string{"cluster_policies"}) } +// TestMarshalerValueReceiver asserts that no adapter surface type declares +// MarshalJSON on a pointer receiver only. +// +// A pointer-receiver MarshalJSON is satisfied by *T but not by T, and +// encoding/json reaches it only for an addressable value. json.Marshal(&x) then +// uses the marshaler while json.Marshal(x) silently falls back to plain +// struct-field encoding -- two code paths for one type, disagreeing on more than +// key order, because encoding/json knows nothing about ForceSendFields (tagged +// json:"-"). A force-sent zero value survives one path and vanishes on the other. +// +// The round-trip tests above cannot catch this: they build values with +// reflect.New, so they only ever marshal a pointer. +func TestMarshalerValueReceiver(t *testing.T) { + marshaler := reflect.TypeFor[json.Marshaler]() + + for resourceType, resource := range SupportedResources { + adapter, err := NewAdapter(resource, resourceType, nil) + require.NoError(t, err) + + t.Run(resourceType, func(t *testing.T) { + for _, surface := range []struct { + label string + typeOf func(*Adapter) reflect.Type + }{ + {"InputConfigType", (*Adapter).InputConfigType}, + {"StateType", (*Adapter).StateType}, + {"RemoteType", (*Adapter).RemoteType}, + } { + typ := surface.typeOf(adapter).Elem() + // A type with no marshaler at all is fine: encoding/json handles it + // the same way by value and by pointer. Only the asymmetry is a bug. + if !reflect.PointerTo(typ).Implements(marshaler) { + continue + } + require.True(t, typ.Implements(marshaler), + "%s %s: %s declares MarshalJSON on a pointer receiver only; change it to a value receiver so marshalling by value and by pointer agree", + surface.label, resourceType, typ) + } + }) + } +} + // fillNonZero recursively populates v with non-zero values so that every // serializable field is observable in a round-trip. It skips ForceSendFields // (json:"-") and bounds recursion depth to avoid runaway on self-referential From 42be3e7f259b905d16ab63dc53261a25f3c7e5bf Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Mon, 7 Sep 2026 16:12:22 +0200 Subject: [PATCH 2/5] Check embedded types too, not just the surface type The first version of the test only looked at each adapter surface type, so it covered 7 of the 8 receivers this branch flips. PostgresRoleConfig is reachable only as an embedded member of PostgresRole -- PostgresRoleState is its own struct rather than an alias to the Config -- and PostgresRole declares its own value receiver, which hides the member's asymmetry from a top-level check. Reverting that one receiver left the test passing. Walk the reachable type graph instead. Reverting all 8 now flags exactly those 8. The walk also covers named fields and collection elements, which is where the same defect would be live rather than latent: marshal's structAsMap stores those into a map via .Interface(), and a map value is not addressable, so a pointer receiver is unreachable there. None exist today. --- bundle/direct/dresources/serialize_test.go | 115 +++++++++++++++------ 1 file changed, 85 insertions(+), 30 deletions(-) diff --git a/bundle/direct/dresources/serialize_test.go b/bundle/direct/dresources/serialize_test.go index e4eaf2cc81b..31c34b37c60 100644 --- a/bundle/direct/dresources/serialize_test.go +++ b/bundle/direct/dresources/serialize_test.go @@ -148,46 +148,101 @@ func TestRoundtripAllFieldsInputConfigType(t *testing.T) { testRoundtripAllFields(t, "InputConfigType", (*Adapter).InputConfigType, []string{"cluster_policies"}) } -// TestMarshalerValueReceiver asserts that no adapter surface type declares -// MarshalJSON on a pointer receiver only. +var jsonMarshalerType = reflect.TypeFor[json.Marshaler]() + +// derefType strips pointer indirection so a type is classified by what it holds. +func derefType(t reflect.Type) reflect.Type { + for t.Kind() == reflect.Pointer { + t = t.Elem() + } + return t +} + +// hasPointerOnlyMarshaler reports whether *T marshals itself but T does not. // -// A pointer-receiver MarshalJSON is satisfied by *T but not by T, and -// encoding/json reaches it only for an addressable value. json.Marshal(&x) then -// uses the marshaler while json.Marshal(x) silently falls back to plain -// struct-field encoding -- two code paths for one type, disagreeing on more than -// key order, because encoding/json knows nothing about ForceSendFields (tagged -// json:"-"). A force-sent zero value survives one path and vanishes on the other. +// A type with no marshaler at all is not a defect: encoding/json treats it the +// same by value and by pointer. Only the asymmetry is. +func hasPointerOnlyMarshaler(t reflect.Type) bool { + return t.Kind() == reflect.Struct && + reflect.PointerTo(t).Implements(jsonMarshalerType) && + !t.Implements(jsonMarshalerType) +} + +// collectPointerOnlyMarshalers records into found every struct type reachable +// from t whose MarshalJSON is declared on a pointer receiver only. seen bounds +// the walk, which terminates because the type graph is finite even though SDK +// types are self-referential. +func collectPointerOnlyMarshalers(t reflect.Type, seen, found map[reflect.Type]bool) { + t = derefType(t) + if seen[t] { + return + } + seen[t] = true + + if hasPointerOnlyMarshaler(t) { + found[t] = true + } + + switch t.Kind() { + case reflect.Slice, reflect.Array, reflect.Map: + collectPointerOnlyMarshalers(t.Elem(), seen, found) + case reflect.Struct: + for field := range t.Fields() { + if field.IsExported() { + collectPointerOnlyMarshalers(field.Type, seen, found) + } + } + default: + // Scalars hold no named type to check, and an interface field's dynamic + // type is not knowable from the static type. + } +} + +// TestMarshalerValueReceiver asserts that no type reachable from an adapter +// surface declares MarshalJSON on a pointer receiver only. +// +// Such a method is satisfied by *T but not by T, and encoding/json reaches it +// only for an addressable value. json.Marshal(&x) then uses the marshaler while +// json.Marshal(x) silently falls back to plain struct-field encoding -- two code +// paths for one type. They disagree on more than key order, because encoding/json +// knows nothing about ForceSendFields (tagged json:"-"), so a force-sent zero +// value of an omitempty field survives one path and vanishes on the other. // -// The round-trip tests above cannot catch this: they build values with +// The walk covers embedded members and named fields, not just the surface type +// itself. Both matter, for different reasons: an embedded member's pointer +// receiver is promoted to *T only, which makes T itself asymmetric unless T +// declares its own marshaler -- and if it does, the member's asymmetry is hidden +// from a top-level check while still applying wherever that member is marshalled +// directly. A named field or collection element is stronger still: marshal's +// structAsMap stores it into a map via .Interface(), and a map value is not +// addressable, so the pointer receiver is unreachable there. +// +// The round-trip tests above cannot catch any of this: they build values with // reflect.New, so they only ever marshal a pointer. func TestMarshalerValueReceiver(t *testing.T) { - marshaler := reflect.TypeFor[json.Marshaler]() + seen := make(map[reflect.Type]bool) + found := make(map[reflect.Type]bool) for resourceType, resource := range SupportedResources { adapter, err := NewAdapter(resource, resourceType, nil) require.NoError(t, err) + for _, typeOf := range []func(*Adapter) reflect.Type{ + (*Adapter).InputConfigType, + (*Adapter).StateType, + (*Adapter).RemoteType, + } { + collectPointerOnlyMarshalers(typeOf(adapter), seen, found) + } + } - t.Run(resourceType, func(t *testing.T) { - for _, surface := range []struct { - label string - typeOf func(*Adapter) reflect.Type - }{ - {"InputConfigType", (*Adapter).InputConfigType}, - {"StateType", (*Adapter).StateType}, - {"RemoteType", (*Adapter).RemoteType}, - } { - typ := surface.typeOf(adapter).Elem() - // A type with no marshaler at all is fine: encoding/json handles it - // the same way by value and by pointer. Only the asymmetry is a bug. - if !reflect.PointerTo(typ).Implements(marshaler) { - continue - } - require.True(t, typ.Implements(marshaler), - "%s %s: %s declares MarshalJSON on a pointer receiver only; change it to a value receiver so marshalling by value and by pointer agree", - surface.label, resourceType, typ) - } - }) + names := make([]string, 0, len(found)) + for t := range found { + names = append(names, t.String()) } + slices.Sort(names) + require.Empty(t, names, + "these types declare MarshalJSON on a pointer receiver only; give each a value receiver so marshalling by value and by pointer agree:\n %s", + strings.Join(names, "\n ")) } // fillNonZero recursively populates v with non-zero values so that every From 553b1baf4a296c6e4bf78039c83da9c68159e590 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Mon, 7 Sep 2026 16:55:00 +0200 Subject: [PATCH 3/5] Widen the detector: named non-struct types, json:"-", recursive pointers Four defects in the detector, from an adversarial review pass: - Restricting to Kind()==Struct skipped named non-struct types, which diverge identically. `type ID string` with a pointer-receiver MarshalJSON as a struct field marshals as {"n":"real"} by value and {"n":"MARSHALER"} by pointer. - derefType stripped pointers in a loop, so `type L *L` -- legal Go, where Elem() returns the type itself -- spun forever. Pointers are now followed as graph edges, terminating on seen. - A type reachable only through a json:"-" field was reported even though it is never serialized. Skipped now, for the same reason unexported fields are. - The doc comment claimed collection elements are non-addressable. Slice elements are addressable and do reach a pointer receiver; only map values (and array elements in a non-addressable slot) do not. Widening reports no new types in the tree, and reverting the 8 receivers still flags exactly those 8. --- bundle/direct/dresources/serialize_test.go | 44 +++++++++++----------- 1 file changed, 21 insertions(+), 23 deletions(-) diff --git a/bundle/direct/dresources/serialize_test.go b/bundle/direct/dresources/serialize_test.go index 31c34b37c60..f358e8eb454 100644 --- a/bundle/direct/dresources/serialize_test.go +++ b/bundle/direct/dresources/serialize_test.go @@ -150,30 +150,23 @@ func TestRoundtripAllFieldsInputConfigType(t *testing.T) { var jsonMarshalerType = reflect.TypeFor[json.Marshaler]() -// derefType strips pointer indirection so a type is classified by what it holds. -func derefType(t reflect.Type) reflect.Type { - for t.Kind() == reflect.Pointer { - t = t.Elem() - } - return t -} - // hasPointerOnlyMarshaler reports whether *T marshals itself but T does not. // // A type with no marshaler at all is not a defect: encoding/json treats it the -// same by value and by pointer. Only the asymmetry is. +// same by value and by pointer. Only the asymmetry is. Kind is not restricted: +// a named scalar, slice or map can declare MarshalJSON on a pointer receiver and +// diverges exactly the same way. func hasPointerOnlyMarshaler(t reflect.Type) bool { - return t.Kind() == reflect.Struct && - reflect.PointerTo(t).Implements(jsonMarshalerType) && + return reflect.PointerTo(t).Implements(jsonMarshalerType) && !t.Implements(jsonMarshalerType) } -// collectPointerOnlyMarshalers records into found every struct type reachable -// from t whose MarshalJSON is declared on a pointer receiver only. seen bounds -// the walk, which terminates because the type graph is finite even though SDK -// types are self-referential. +// collectPointerOnlyMarshalers records into found every type reachable from t +// whose MarshalJSON is declared on a pointer receiver only. +// +// Pointers are followed as edges rather than stripped up front, so `type L *L` +// terminates on seen rather than spinning in Elem(). func collectPointerOnlyMarshalers(t reflect.Type, seen, found map[reflect.Type]bool) { - t = derefType(t) if seen[t] { return } @@ -184,16 +177,19 @@ func collectPointerOnlyMarshalers(t reflect.Type, seen, found map[reflect.Type]b } switch t.Kind() { - case reflect.Slice, reflect.Array, reflect.Map: + case reflect.Pointer, reflect.Slice, reflect.Array, reflect.Map: collectPointerOnlyMarshalers(t.Elem(), seen, found) case reflect.Struct: for field := range t.Fields() { - if field.IsExported() { - collectPointerOnlyMarshalers(field.Type, seen, found) + // json:"-" is never serialized, so a type reachable only through one + // cannot diverge. Skipped for the same reason as unexported fields. + if !field.IsExported() || structtag.JSONTag(field.Tag.Get("json")).Name() == "-" { + continue } + collectPointerOnlyMarshalers(field.Type, seen, found) } default: - // Scalars hold no named type to check, and an interface field's dynamic + // Scalars hold no reachable named type, and an interface field's dynamic // type is not knowable from the static type. } } @@ -213,9 +209,11 @@ func collectPointerOnlyMarshalers(t reflect.Type, seen, found map[reflect.Type]b // receiver is promoted to *T only, which makes T itself asymmetric unless T // declares its own marshaler -- and if it does, the member's asymmetry is hidden // from a top-level check while still applying wherever that member is marshalled -// directly. A named field or collection element is stronger still: marshal's -// structAsMap stores it into a map via .Interface(), and a map value is not -// addressable, so the pointer receiver is unreachable there. +// directly. A named field is stronger still: marshal's structAsMap stores every +// field into a map via .Interface(), and a map value is not addressable, so the +// pointer receiver is unreachable there. (Slice elements, by contrast, stay +// addressable and do reach it -- the walk covers them for the embedded-member +// reason, not this one.) // // The round-trip tests above cannot catch any of this: they build values with // reflect.New, so they only ever marshal a pointer. From 80ef0daee0bacfbf28006323f8f44ee443ff6ea4 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Tue, 8 Sep 2026 10:39:16 +0200 Subject: [PATCH 4/5] Report per resource type, like the other tests in the file TestMarshalerValueReceiver accumulated every surface into one shared walk and asserted once at the end, unlike its neighbours which subtest per resource type. The reason given was deduplication, and it does not hold: measured at the pre-fix baseline, each violating type is reachable from exactly one resource, so there is nothing to deduplicate. Subtest per resource type instead. A failure now names the resource, so `-run TestMarshalerValueReceiver/postgres_roles` selects it. Each subtest owns its maps rather than sharing them, since a shared seen map would attribute a type to whichever subtest reached it first and iteration order over SupportedResources is random. That re-walks shared SDK types: 1628 type visits instead of 1111, 3.8ms instead of 0.8ms, against a test that already takes ~0.6s. --- bundle/direct/dresources/serialize_test.go | 41 ++++++++++++---------- 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/bundle/direct/dresources/serialize_test.go b/bundle/direct/dresources/serialize_test.go index f358e8eb454..17b943a4e3e 100644 --- a/bundle/direct/dresources/serialize_test.go +++ b/bundle/direct/dresources/serialize_test.go @@ -218,29 +218,34 @@ func collectPointerOnlyMarshalers(t reflect.Type, seen, found map[reflect.Type]b // The round-trip tests above cannot catch any of this: they build values with // reflect.New, so they only ever marshal a pointer. func TestMarshalerValueReceiver(t *testing.T) { - seen := make(map[reflect.Type]bool) - found := make(map[reflect.Type]bool) - for resourceType, resource := range SupportedResources { adapter, err := NewAdapter(resource, resourceType, nil) require.NoError(t, err) - for _, typeOf := range []func(*Adapter) reflect.Type{ - (*Adapter).InputConfigType, - (*Adapter).StateType, - (*Adapter).RemoteType, - } { - collectPointerOnlyMarshalers(typeOf(adapter), seen, found) - } - } - names := make([]string, 0, len(found)) - for t := range found { - names = append(names, t.String()) + t.Run(resourceType, func(t *testing.T) { + // Each subtest walks with its own maps. Sharing them across subtests + // would attribute a type to whichever one reached it first, and + // iteration order over SupportedResources is random. + seen := make(map[reflect.Type]bool) + found := make(map[reflect.Type]bool) + for _, typeOf := range []func(*Adapter) reflect.Type{ + (*Adapter).InputConfigType, + (*Adapter).StateType, + (*Adapter).RemoteType, + } { + collectPointerOnlyMarshalers(typeOf(adapter), seen, found) + } + + names := make([]string, 0, len(found)) + for typ := range found { + names = append(names, typ.String()) + } + slices.Sort(names) + require.Empty(t, names, + "reachable from %s: these types declare MarshalJSON on a pointer receiver only; give each a value receiver so marshalling by value and by pointer agree:\n %s", + resourceType, strings.Join(names, "\n ")) + }) } - slices.Sort(names) - require.Empty(t, names, - "these types declare MarshalJSON on a pointer receiver only; give each a value receiver so marshalling by value and by pointer agree:\n %s", - strings.Join(names, "\n ")) } // fillNonZero recursively populates v with non-zero values so that every From a16e96060f98c2cfc5a87edc79ede7a52a517282 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Tue, 8 Sep 2026 11:07:01 +0200 Subject: [PATCH 5/5] Add explicit marshal regression tests for postgres_branch TestMarshalerValueReceiver asserts the invariant reflectively; these pin the concrete behaviour it protects, as a plain marshal-and-compare that reads at a glance. Both fail with a pointer receiver and pass with a value one: - ForceSendFields on an omitempty zero value (purge_on_delete) is emitted only by the SDK marshaler; the plain encoding/json fallback a value takes under a pointer receiver would drop it. - json.Marshal(x) and json.Marshal(&x) produce identical bytes. --- .../config/resources/postgres_branch_test.go | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 bundle/config/resources/postgres_branch_test.go diff --git a/bundle/config/resources/postgres_branch_test.go b/bundle/config/resources/postgres_branch_test.go new file mode 100644 index 00000000000..03f87de8ac6 --- /dev/null +++ b/bundle/config/resources/postgres_branch_test.go @@ -0,0 +1,44 @@ +package resources + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestPostgresBranchConfigMarshalHonorsForceSendFields pins the reason +// PostgresBranchConfig.MarshalJSON is declared on a value receiver. +// +// PurgeOnDelete is omitempty, so its zero value is emitted only because +// ForceSendFields names it, and the SDK marshaler is what honors ForceSendFields. +// With a pointer-receiver MarshalJSON, only *T satisfies json.Marshaler, so +// marshalling a value falls back to plain encoding/json -- which ignores +// ForceSendFields (tagged json:"-") and would drop the field. +func TestPostgresBranchConfigMarshalHonorsForceSendFields(t *testing.T) { + c := PostgresBranchConfig{BranchId: "b1", Parent: "projects/p1"} + c.ForceSendFields = []string{"PurgeOnDelete"} + + b, err := json.Marshal(c) + require.NoError(t, err) + assert.JSONEq(t, `{"branch_id":"b1","parent":"projects/p1","purge_on_delete":false}`, string(b)) +} + +// TestPostgresBranchMarshalValueAndPointerAgree guards the invariant directly: +// a value-receiver MarshalJSON makes json.Marshal(x) and json.Marshal(&x) +// produce the same bytes. A pointer-only marshaler would send them down two +// different code paths. +func TestPostgresBranchMarshalValueAndPointerAgree(t *testing.T) { + b := PostgresBranch{} + b.ID = "the-id" + b.BranchId = "b1" + b.Parent = "projects/p1" + + byValue, err := json.Marshal(b) + require.NoError(t, err) + byPointer, err := json.Marshal(&b) + require.NoError(t, err) + assert.Equal(t, string(byPointer), string(byValue)) + assert.JSONEq(t, `{"id":"the-id","lifecycle":{},"branch_id":"b1","parent":"projects/p1"}`, string(byValue)) +}