diff --git a/components/onboarding/internal/adapters/postgres/account/account.postgresql.go b/components/onboarding/internal/adapters/postgres/account/account.postgresql.go index 585ebbd16..e2758fc2a 100644 --- a/components/onboarding/internal/adapters/postgres/account/account.postgresql.go +++ b/components/onboarding/internal/adapters/postgres/account/account.postgresql.go @@ -53,7 +53,7 @@ type Repository interface { FindByAlias(ctx context.Context, organizationID, ledgerID uuid.UUID, alias string) (bool, error) ListByIDs(ctx context.Context, organizationID, ledgerID uuid.UUID, portfolioID *uuid.UUID, ids []uuid.UUID) ([]*mmodel.Account, error) ListByAlias(ctx context.Context, organizationID, ledgerID, portfolioID uuid.UUID, alias []string) ([]*mmodel.Account, error) - Update(ctx context.Context, organizationID, ledgerID uuid.UUID, portfolioID *uuid.UUID, id uuid.UUID, acc *mmodel.Account) (*mmodel.Account, error) + Update(ctx context.Context, organizationID, ledgerID uuid.UUID, portfolioID *uuid.UUID, id uuid.UUID, uai *mmodel.UpdateAccountInput) (*mmodel.Account, error) Delete(ctx context.Context, organizationID, ledgerID uuid.UUID, portfolioID *uuid.UUID, id uuid.UUID) error ListAccountsByIDs(ctx context.Context, organizationID, ledgerID uuid.UUID, ids []uuid.UUID) ([]*mmodel.Account, error) ListAccountsByAlias(ctx context.Context, organizationID, ledgerID uuid.UUID, aliases []string) ([]*mmodel.Account, error) @@ -806,7 +806,7 @@ func (r *AccountPostgreSQLRepository) ListByAlias(ctx context.Context, organizat } // Update an Account entity into Postgresql and returns the Account updated. -func (r *AccountPostgreSQLRepository) Update(ctx context.Context, organizationID, ledgerID uuid.UUID, portfolioID *uuid.UUID, id uuid.UUID, acc *mmodel.Account) (*mmodel.Account, error) { +func (r *AccountPostgreSQLRepository) Update(ctx context.Context, organizationID, ledgerID uuid.UUID, portfolioID *uuid.UUID, id uuid.UUID, uai *mmodel.UpdateAccountInput) (*mmodel.Account, error) { logger, tracer, _, _ := libCommons.NewTrackingFromContext(ctx) ctx, span := tracer.Start(ctx, "postgres.update_account") @@ -821,42 +821,48 @@ func (r *AccountPostgreSQLRepository) Update(ctx context.Context, organizationID return nil, err } - record := &AccountPostgreSQLModel{} - record.FromEntity(acc) - builder := squirrel.Update(r.tableName) - if acc.Name != "" { - builder = builder.Set("name", record.Name) - } - - if !acc.Status.IsEmpty() { - builder = builder.Set("status", record.Status) - builder = builder.Set("status_description", record.StatusDescription) + if uai.Name != "" { + builder = builder.Set("name", uai.Name) } - if !libCommons.IsNilOrEmpty(acc.Alias) { - builder = builder.Set("alias", record.Alias) + if !uai.Status.IsEmpty() { + builder = builder.Set("status", uai.Status.Code) + builder = builder.Set("status_description", uai.Status.Description) } - if acc.Blocked != nil { - builder = builder.Set("blocked", *acc.Blocked) + if uai.Blocked != nil { + builder = builder.Set("blocked", *uai.Blocked) } - if !libCommons.IsNilOrEmpty(acc.SegmentID) { - builder = builder.Set("segment_id", record.SegmentID) + // Handle nullable fields - these can be set to NULL explicitly + if uai.SegmentID.ShouldUpdate() { + if uai.SegmentID.ShouldSetNull() { + builder = builder.Set("segment_id", nil) + } else { + builder = builder.Set("segment_id", uai.SegmentID.Value) + } } - if !libCommons.IsNilOrEmpty(acc.EntityID) { - builder = builder.Set("entity_id", record.EntityID) + if uai.EntityID.ShouldUpdate() { + if uai.EntityID.ShouldSetNull() { + builder = builder.Set("entity_id", nil) + } else { + builder = builder.Set("entity_id", uai.EntityID.Value) + } } - if !libCommons.IsNilOrEmpty(acc.PortfolioID) { - builder = builder.Set("portfolio_id", record.PortfolioID) + if uai.PortfolioID.ShouldUpdate() { + if uai.PortfolioID.ShouldSetNull() { + builder = builder.Set("portfolio_id", nil) + } else { + builder = builder.Set("portfolio_id", uai.PortfolioID.Value) + } } - record.UpdatedAt = time.Now() - builder = builder.Set("updated_at", record.UpdatedAt) + updatedAt := time.Now() + builder = builder.Set("updated_at", updatedAt) builder = builder.Where(squirrel.Eq{"organization_id": organizationID}) builder = builder.Where(squirrel.Eq{"ledger_id": ledgerID}) @@ -919,7 +925,8 @@ func (r *AccountPostgreSQLRepository) Update(ctx context.Context, organizationID return nil, err } - return record.ToEntity(), nil + // Fetch the updated account to return + return r.Find(ctx, organizationID, ledgerID, portfolioID, id) } // Delete an Account entity from the database (soft delete) using the provided ID. diff --git a/components/onboarding/internal/adapters/postgres/account/account.postgresql_mock.go b/components/onboarding/internal/adapters/postgres/account/account.postgresql_mock.go index 48c83d4c3..0fad071d6 100644 --- a/components/onboarding/internal/adapters/postgres/account/account.postgresql_mock.go +++ b/components/onboarding/internal/adapters/postgres/account/account.postgresql_mock.go @@ -208,18 +208,18 @@ func (mr *MockRepositoryMockRecorder) ListByIDs(ctx, organizationID, ledgerID, p } // Update mocks base method. -func (m *MockRepository) Update(ctx context.Context, organizationID, ledgerID uuid.UUID, portfolioID *uuid.UUID, id uuid.UUID, acc *mmodel.Account) (*mmodel.Account, error) { +func (m *MockRepository) Update(ctx context.Context, organizationID, ledgerID uuid.UUID, portfolioID *uuid.UUID, id uuid.UUID, uai *mmodel.UpdateAccountInput) (*mmodel.Account, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Update", ctx, organizationID, ledgerID, portfolioID, id, acc) + ret := m.ctrl.Call(m, "Update", ctx, organizationID, ledgerID, portfolioID, id, uai) ret0, _ := ret[0].(*mmodel.Account) ret1, _ := ret[1].(error) return ret0, ret1 } // Update indicates an expected call of Update. -func (mr *MockRepositoryMockRecorder) Update(ctx, organizationID, ledgerID, portfolioID, id, acc any) *gomock.Call { +func (mr *MockRepositoryMockRecorder) Update(ctx, organizationID, ledgerID, portfolioID, id, uai any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockRepository)(nil).Update), ctx, organizationID, ledgerID, portfolioID, id, acc) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockRepository)(nil).Update), ctx, organizationID, ledgerID, portfolioID, id, uai) } // Count mocks base method. diff --git a/components/onboarding/internal/services/command/update-account.go b/components/onboarding/internal/services/command/update-account.go index 9c16a4929..af6979dc6 100644 --- a/components/onboarding/internal/services/command/update-account.go +++ b/components/onboarding/internal/services/command/update-account.go @@ -36,17 +36,7 @@ func (uc *UseCase) UpdateAccount(ctx context.Context, organizationID, ledgerID u return nil, pkg.ValidateBusinessError(constant.ErrForbiddenExternalAccountManipulation, reflect.TypeOf(mmodel.Account{}).Name()) } - account := &mmodel.Account{ - Name: uai.Name, - Status: uai.Status, - EntityID: uai.EntityID, - SegmentID: uai.SegmentID, - PortfolioID: uai.PortfolioID, - Metadata: uai.Metadata, - Blocked: uai.Blocked, - } - - accountUpdated, err := uc.AccountRepo.Update(ctx, organizationID, ledgerID, portfolioID, id, account) + accountUpdated, err := uc.AccountRepo.Update(ctx, organizationID, ledgerID, portfolioID, id, uai) if err != nil { logger.Errorf("Error updating account on repo by id: %v", err) diff --git a/components/onboarding/internal/services/command/update-account_test.go b/components/onboarding/internal/services/command/update-account_test.go index 538b2bf78..a14fda6e3 100644 --- a/components/onboarding/internal/services/command/update-account_test.go +++ b/components/onboarding/internal/services/command/update-account_test.go @@ -3,6 +3,8 @@ package command import ( "context" "errors" + "testing" + "github.com/LerianStudio/midaz/v3/components/onboarding/internal/adapters/mongodb" "github.com/LerianStudio/midaz/v3/components/onboarding/internal/adapters/postgres/account" "github.com/LerianStudio/midaz/v3/components/onboarding/internal/services" @@ -10,7 +12,6 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/assert" "go.uber.org/mock/gomock" - "testing" ) func TestUpdateAccount(t *testing.T) { @@ -46,8 +47,8 @@ func TestUpdateAccount(t *testing.T) { Status: mmodel.Status{ Code: "active", }, - SegmentID: nil, - Metadata: map[string]any{"key": "value"}, + // SegmentID omitted - uses zero value (unset) + Metadata: map[string]any{"key": "value"}, }, mockSetup: func() { mockAccountRepo.EXPECT(). @@ -76,8 +77,8 @@ func TestUpdateAccount(t *testing.T) { Status: mmodel.Status{ Code: "active", }, - SegmentID: nil, - Metadata: nil, + // SegmentID omitted - uses zero value (unset) + Metadata: nil, }, mockSetup: func() { mockAccountRepo.EXPECT(). @@ -97,8 +98,8 @@ func TestUpdateAccount(t *testing.T) { Status: mmodel.Status{ Code: "active", }, - SegmentID: nil, - Metadata: map[string]any{"key": "value"}, + // SegmentID omitted - uses zero value (unset) + Metadata: map[string]any{"key": "value"}, }, mockSetup: func() { mockAccountRepo.EXPECT(). @@ -163,12 +164,12 @@ func TestUpdateAccount_BlockedProvidedTrue(t *testing.T) { mockAccountRepo.EXPECT(). Update(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - DoAndReturn(func(_ context.Context, _ uuid.UUID, _ uuid.UUID, _ *uuid.UUID, _ uuid.UUID, acc *mmodel.Account) (*mmodel.Account, error) { - if acc.Blocked == nil || !*acc.Blocked { - t.Fatalf("expected acc.Blocked to be true and non-nil") + DoAndReturn(func(_ context.Context, _ uuid.UUID, _ uuid.UUID, _ *uuid.UUID, _ uuid.UUID, uai *mmodel.UpdateAccountInput) (*mmodel.Account, error) { + if uai.Blocked == nil || !*uai.Blocked { + t.Fatalf("expected uai.Blocked to be true and non-nil") } // Echo back - return &mmodel.Account{ID: accountID.String(), Name: "Updated Account", Status: mmodel.Status{Code: "active"}, Blocked: acc.Blocked}, nil + return &mmodel.Account{ID: accountID.String(), Name: "Updated Account", Status: mmodel.Status{Code: "active"}, Blocked: uai.Blocked}, nil }) mockMetadataRepo.EXPECT(). @@ -218,9 +219,9 @@ func TestUpdateAccount_BlockedOmitted(t *testing.T) { mockAccountRepo.EXPECT(). Update(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - DoAndReturn(func(_ context.Context, _ uuid.UUID, _ uuid.UUID, _ *uuid.UUID, _ uuid.UUID, acc *mmodel.Account) (*mmodel.Account, error) { - if acc.Blocked != nil { - t.Fatalf("expected acc.Blocked to be nil when omitted") + DoAndReturn(func(_ context.Context, _ uuid.UUID, _ uuid.UUID, _ *uuid.UUID, _ uuid.UUID, uai *mmodel.UpdateAccountInput) (*mmodel.Account, error) { + if uai.Blocked != nil { + t.Fatalf("expected uai.Blocked to be nil when omitted") } return &mmodel.Account{ID: accountID.String(), Name: "Updated Account"}, nil }) diff --git a/pkg/mmodel/account.go b/pkg/mmodel/account.go index 18b5017b7..f9ba87fd9 100644 --- a/pkg/mmodel/account.go +++ b/pkg/mmodel/account.go @@ -3,12 +3,14 @@ package mmodel import ( "time" + "github.com/LerianStudio/midaz/v3/pkg/nullable" "github.com/google/uuid" ) // CreateAccountInput is a struct designed to encapsulate request create payload data. // // swagger:model CreateAccountInput +// // @Description Request payload for creating a new account within a ledger. Accounts represent individual financial entities such as bank accounts, credit cards, expense categories, or any other financial buckets within a ledger. Accounts are identified by a unique ID, can have aliases for easy reference, and are associated with a specific asset type. // // @example { @@ -89,6 +91,7 @@ type CreateAccountInput struct { // UpdateAccountInput is a struct designed to encapsulate request update payload data. // // swagger:model UpdateAccountInput +// // @Description Request payload for updating an existing account. All fields are optional - only specified fields will be updated. Omitted fields will remain unchanged. This allows partial updates to account properties such as name, status, portfolio, segment, and metadata. // // @example { @@ -109,21 +112,21 @@ type UpdateAccountInput struct { // maxLength: 256 Name string `json:"name" validate:"max=256" example:"Primary Corporate Checking Account" maxLength:"256"` - // Updated segment ID for the account + // Updated segment ID for the account (use null to unlink from segment) // required: false // format: uuid - SegmentID *string `json:"segmentId" validate:"omitempty,uuid" format:"uuid"` + SegmentID nullable.Nullable[string] `json:"segmentId" validate:"omitempty,uuid" format:"uuid" swaggertype:"string"` - // Updated portfolio ID for the account + // Updated portfolio ID for the account (use null to unlink from portfolio) // required: false // format: uuid - PortfolioID *string `json:"portfolioId" validate:"omitempty,uuid" format:"uuid"` + PortfolioID nullable.Nullable[string] `json:"portfolioId" validate:"omitempty,uuid" format:"uuid" swaggertype:"string"` - // Optional external identifier for linking to external systems + // Optional external identifier for linking to external systems (use null to remove) // required: false // example: EXT-ACC-12345 // maxLength: 256 - EntityID *string `json:"entityId" validate:"omitempty,max=256" example:"EXT-ACC-12345" maxLength:"256"` + EntityID nullable.Nullable[string] `json:"entityId" validate:"omitempty,max=256" example:"EXT-ACC-12345" maxLength:"256" swaggertype:"string"` // Updated status of the account // required: false @@ -142,6 +145,7 @@ type UpdateAccountInput struct { // Account is a struct designed to encapsulate response payload data. // // swagger:model Account +// // @Description Complete account entity containing all fields including system-generated fields like ID, creation timestamps, and metadata. This is the response format for account operations. Accounts represent individual financial entities (bank accounts, cards, expense categories, etc.) within a ledger and are the primary structures for tracking balances and transactions. // // @example { @@ -256,6 +260,7 @@ func (a *Account) IDtoUUID() uuid.UUID { // Accounts struct to return a paginated list of accounts. // // swagger:model Accounts +// // @Description Paginated list of accounts with metadata about the current page, limit, and the account items themselves. Used for list operations. // // @example { @@ -310,6 +315,7 @@ type Accounts struct { // AccountResponse represents a success response containing a single account. // // swagger:response AccountResponse +// // @Description Successful response containing a single account entity. type AccountResponse struct { // in: body @@ -319,6 +325,7 @@ type AccountResponse struct { // AccountsResponse represents a success response containing a paginated list of accounts. // // swagger:response AccountsResponse +// // @Description Successful response containing a paginated list of accounts. type AccountsResponse struct { // in: body @@ -328,6 +335,7 @@ type AccountsResponse struct { // AccountErrorResponse represents an error response for account operations. // // swagger:response AccountErrorResponse +// // @Description Error response for account operations with error code and message. // // @example { diff --git a/pkg/net/http/withBody.go b/pkg/net/http/withBody.go index a2a8d9d80..76c2fef32 100644 --- a/pkg/net/http/withBody.go +++ b/pkg/net/http/withBody.go @@ -13,6 +13,7 @@ import ( libOpentelemetry "github.com/LerianStudio/lib-commons/v2/commons/opentelemetry" "github.com/LerianStudio/midaz/v3/pkg" cn "github.com/LerianStudio/midaz/v3/pkg/constant" + "github.com/LerianStudio/midaz/v3/pkg/nullable" pkgTransaction "github.com/LerianStudio/midaz/v3/pkg/transaction" "github.com/go-playground/locales/en" ut "github.com/go-playground/universal-translator" @@ -218,6 +219,17 @@ func newValidator() (*validator.Validate, ut.Translator) { panic(err) } + // Register custom type func for nullable.Nullable[string] to extract inner value for validation + v.RegisterCustomTypeFunc(func(field reflect.Value) any { + if n, ok := field.Interface().(nullable.Nullable[string]); ok { + if n.IsSet && !n.IsNull { + return n.Value + } + } + + return nil + }, nullable.Nullable[string]{}) + v.RegisterTagNameFunc(func(fld reflect.StructField) string { name := strings.SplitN(fld.Tag.Get("json"), ",", 2)[0] if name == "-" { diff --git a/pkg/nullable/nullable.go b/pkg/nullable/nullable.go new file mode 100644 index 000000000..d972a6574 --- /dev/null +++ b/pkg/nullable/nullable.go @@ -0,0 +1,117 @@ +// Package nullable provides a generic Nullable type for handling JSON fields +// that need to distinguish between "not provided" and "explicitly set to null". +// +// This solves the common REST API problem where PATCH requests need to: +// - Ignore fields not present in the request (keep existing value) +// - Set fields to NULL when explicitly sent as null +// - Update fields when sent with a value +package nullable + +import ( + "bytes" + "encoding/json" +) + +// Nullable represents a value that can be in three states: +// 1. Not set (absent from JSON) - IsSet=false, IsNull=false +// 2. Explicitly null - IsSet=true, IsNull=true +// 3. Has a value - IsSet=true, IsNull=false, Value contains the data +type Nullable[T any] struct { + Value T + IsSet bool + IsNull bool +} + +// Set creates a Nullable with a value. +func Set[T any](value T) Nullable[T] { + return Nullable[T]{ + Value: value, + IsSet: true, + IsNull: false, + } +} + +// Null creates a Nullable that is explicitly null. +func Null[T any]() Nullable[T] { + return Nullable[T]{ + IsSet: true, + IsNull: true, + } +} + +// Unset creates a Nullable that was not provided. +func Unset[T any]() Nullable[T] { + return Nullable[T]{ + IsSet: false, + IsNull: false, + } +} + +// UnmarshalJSON implements json.Unmarshaler. +// This is the key method that distinguishes between null and missing values. +func (n *Nullable[T]) UnmarshalJSON(data []byte) error { + // If we're being called, the field was present in the JSON + n.IsSet = true + + // Check if the value is explicitly null + if bytes.Equal(data, []byte("null")) { + n.IsNull = true + + return nil + } + + // Otherwise, unmarshal the value + n.IsNull = false + + return json.Unmarshal(data, &n.Value) +} + +// MarshalJSON implements json.Marshaler. +func (n Nullable[T]) MarshalJSON() ([]byte, error) { + if !n.IsSet || n.IsNull { + return []byte("null"), nil + } + + return json.Marshal(n.Value) +} + +// Get returns the value and a boolean indicating if it's valid (set and not null). +func (n Nullable[T]) Get() (T, bool) { + if n.IsSet && !n.IsNull { + return n.Value, true + } + + var zero T + + return zero, false +} + +// GetOrDefault returns the value if set and not null, otherwise returns the default. +func (n Nullable[T]) GetOrDefault(defaultValue T) T { + if n.IsSet && !n.IsNull { + return n.Value + } + + return defaultValue +} + +// ToPointer converts to a pointer (nil if not set or null, pointer to value otherwise). +// Useful for compatibility with existing code that uses *string, *bool, etc. +func (n Nullable[T]) ToPointer() *T { + if n.IsSet && !n.IsNull { + return &n.Value + } + + return nil +} + +// ShouldUpdate returns true if this field should be included in an UPDATE query. +// This is the key method for solving the PATCH problem. +func (n Nullable[T]) ShouldUpdate() bool { + return n.IsSet +} + +// ShouldSetNull returns true if this field should be SET to NULL. +func (n Nullable[T]) ShouldSetNull() bool { + return n.IsSet && n.IsNull +} diff --git a/pkg/nullable/nullable_test.go b/pkg/nullable/nullable_test.go new file mode 100644 index 000000000..adeef56a8 --- /dev/null +++ b/pkg/nullable/nullable_test.go @@ -0,0 +1,215 @@ +package nullable + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNullable_UnmarshalJSON_NotProvided(t *testing.T) { + type TestStruct struct { + Name string `json:"name"` + SegmentID Nullable[string] `json:"segmentId"` + } + + // JSON without the segmentId field + jsonData := `{"name": "Test Account"}` + + var result TestStruct + + err := json.Unmarshal([]byte(jsonData), &result) + + require.NoError(t, err) + assert.Equal(t, "Test Account", result.Name) + assert.False(t, result.SegmentID.IsSet, "SegmentID should not be set when absent from JSON") + assert.False(t, result.SegmentID.IsNull, "SegmentID should not be null when absent from JSON") + assert.False(t, result.SegmentID.ShouldUpdate(), "ShouldUpdate should be false when field is absent") +} + +func TestNullable_UnmarshalJSON_ExplicitNull(t *testing.T) { + type TestStruct struct { + Name string `json:"name"` + SegmentID Nullable[string] `json:"segmentId"` + } + + // JSON with explicit null for segmentId + jsonData := `{"name": "Test Account", "segmentId": null}` + + var result TestStruct + + err := json.Unmarshal([]byte(jsonData), &result) + + require.NoError(t, err) + assert.Equal(t, "Test Account", result.Name) + assert.True(t, result.SegmentID.IsSet, "SegmentID should be set when explicitly null") + assert.True(t, result.SegmentID.IsNull, "SegmentID should be null when explicitly null") + assert.True(t, result.SegmentID.ShouldUpdate(), "ShouldUpdate should be true when field is explicitly null") + assert.True(t, result.SegmentID.ShouldSetNull(), "ShouldSetNull should be true when field is explicitly null") +} + +func TestNullable_UnmarshalJSON_WithValue(t *testing.T) { + type TestStruct struct { + Name string `json:"name"` + SegmentID Nullable[string] `json:"segmentId"` + } + + // JSON with a value for segmentId + jsonData := `{"name": "Test Account", "segmentId": "550e8400-e29b-41d4-a716-446655440000"}` + + var result TestStruct + + err := json.Unmarshal([]byte(jsonData), &result) + + require.NoError(t, err) + assert.Equal(t, "Test Account", result.Name) + assert.True(t, result.SegmentID.IsSet, "SegmentID should be set when has value") + assert.False(t, result.SegmentID.IsNull, "SegmentID should not be null when has value") + assert.Equal(t, "550e8400-e29b-41d4-a716-446655440000", result.SegmentID.Value) + assert.True(t, result.SegmentID.ShouldUpdate(), "ShouldUpdate should be true when field has value") + assert.False(t, result.SegmentID.ShouldSetNull(), "ShouldSetNull should be false when field has value") +} + +func TestNullable_Get(t *testing.T) { + // Test with value + withValue := Set("test-value") + + val, ok := withValue.Get() + assert.True(t, ok) + assert.Equal(t, "test-value", val) + + // Test with null + withNull := Null[string]() + + val, ok = withNull.Get() + assert.False(t, ok) + assert.Equal(t, "", val) + + // Test unset + unset := Unset[string]() + + val, ok = unset.Get() + assert.False(t, ok) + assert.Equal(t, "", val) +} + +func TestNullable_ToPointer(t *testing.T) { + // Test with value + withValue := Set("test-value") + + ptr := withValue.ToPointer() + require.NotNil(t, ptr) + assert.Equal(t, "test-value", *ptr) + + // Test with null + withNull := Null[string]() + + ptr = withNull.ToPointer() + assert.Nil(t, ptr) + + // Test unset + unset := Unset[string]() + + ptr = unset.ToPointer() + assert.Nil(t, ptr) +} + +func TestNullable_MarshalJSON(t *testing.T) { + type TestStruct struct { + Name string `json:"name"` + SegmentID Nullable[string] `json:"segmentId"` + } + + // Test with value + withValue := TestStruct{Name: "Test", SegmentID: Set("test-id")} + + data, err := json.Marshal(withValue) + require.NoError(t, err) + assert.JSONEq(t, `{"name":"Test","segmentId":"test-id"}`, string(data)) + + // Test with null + withNull := TestStruct{Name: "Test", SegmentID: Null[string]()} + + data, err = json.Marshal(withNull) + require.NoError(t, err) + assert.JSONEq(t, `{"name":"Test","segmentId":null}`, string(data)) +} + +func TestNullable_Bool(t *testing.T) { + type TestStruct struct { + Blocked Nullable[bool] `json:"blocked"` + } + + // Test explicit false (important: false should be different from null/absent) + jsonData := `{"blocked": false}` + + var result TestStruct + + err := json.Unmarshal([]byte(jsonData), &result) + + require.NoError(t, err) + assert.True(t, result.Blocked.IsSet) + assert.False(t, result.Blocked.IsNull) + assert.False(t, result.Blocked.Value) + assert.True(t, result.Blocked.ShouldUpdate()) +} + +func TestNullable_GetOrDefault(t *testing.T) { + // Test with value + withValue := Set("actual-value") + assert.Equal(t, "actual-value", withValue.GetOrDefault("default")) + + // Test with null + withNull := Null[string]() + assert.Equal(t, "default", withNull.GetOrDefault("default")) + + // Test unset + unset := Unset[string]() + assert.Equal(t, "default", unset.GetOrDefault("default")) +} + +// TestPatchScenario simulates the real-world PATCH scenario from issue #1778 +func TestPatchScenario_UnlinkAccountFromSegment(t *testing.T) { + type UpdateAccountInput struct { + Name string `json:"name"` + SegmentID Nullable[string] `json:"segmentId"` + } + + // Scenario: User wants to unlink account from segment by sending null + jsonData := `{"segmentId": null}` + + var input UpdateAccountInput + + err := json.Unmarshal([]byte(jsonData), &input) + + require.NoError(t, err) + + // The key assertion: we should know to SET segment_id to NULL + assert.True(t, input.SegmentID.ShouldUpdate(), "Should update segment_id in DB") + assert.True(t, input.SegmentID.ShouldSetNull(), "Should set segment_id to NULL") +} + +// TestPatchScenario_PartialUpdate simulates updating only specific fields +func TestPatchScenario_PartialUpdate(t *testing.T) { + type UpdateAccountInput struct { + Name string `json:"name"` + SegmentID Nullable[string] `json:"segmentId"` + PortfolioID Nullable[string] `json:"portfolioId"` + EntityID Nullable[string] `json:"entityId"` + } + + // Scenario: User wants to update only the name, keep everything else + jsonData := `{"name": "New Account Name"}` + + var input UpdateAccountInput + + err := json.Unmarshal([]byte(jsonData), &input) + + require.NoError(t, err) + + assert.Equal(t, "New Account Name", input.Name) + assert.False(t, input.SegmentID.ShouldUpdate(), "Should NOT update segment_id") + assert.False(t, input.PortfolioID.ShouldUpdate(), "Should NOT update portfolio_id") + assert.False(t, input.EntityID.ShouldUpdate(), "Should NOT update entity_id") +}