From 631cb5db26dc7b95f5ee947fcee6a0313e2cfd50 Mon Sep 17 00:00:00 2001 From: sinchubhat Date: Mon, 13 Jul 2026 13:07:04 +0530 Subject: [PATCH] feat: add discovery timestamps to deviceInfo Add firstDiscovered (immutable) and rename lastUpdated to lastSynced. Related to device-management-toolkit/rpc-go#1394 --- .../console_mps_apis.postman_collection.json | 2 +- .../controller/httpapi/v1/devices_test.go | 22 ++- internal/controller/openapi/devices.go | 6 +- internal/entity/dto/v1/device.go | 26 ++- internal/entity/dto/v1/device_test.go | 38 +++- internal/usecase/devices/repo_test.go | 177 ++++++++++++++++-- internal/usecase/devices/usecase.go | 44 ++++- 7 files changed, 269 insertions(+), 46 deletions(-) diff --git a/integration-test/collections/console_mps_apis.postman_collection.json b/integration-test/collections/console_mps_apis.postman_collection.json index da421cd8e..982ff4e47 100644 --- a/integration-test/collections/console_mps_apis.postman_collection.json +++ b/integration-test/collections/console_mps_apis.postman_collection.json @@ -1633,7 +1633,7 @@ "header": [], "body": { "mode": "raw", - "raw": "{\r\n \"guid\": \"143e4567-e89b-12d3-a456-426614174000\",\r\n \"friendlyName\": \"friendlyName\",\r\n \"hostname\": \"hostname\",\r\n \"tags\": [],\r\n \"mpsusername\": \"admin\",\r\n \"deviceInfo\": {\r\n \"fwVersion\": \"16.1.30\",\r\n \"fwBuild\": \"3400\",\r\n \"fwSku\": \"11\",\r\n \"discovered\": true,\r\n \"currentMode\": \"Admin\",\r\n \"features\": \"SOL,IDER,KVM\",\r\n \"ipAddress\": \"10.0.0.12\",\r\n \"lastUpdated\": \"2026-05-21T00:00:00Z\",\r\n \"tlsMode\": \"TLS 1.2\",\r\n \"upid\": {\r\n \"oemPlatformIdType\": \"Not Set (0)\",\r\n \"oemId\": \"\",\r\n \"csmeId\": \"4A45A39C5ED9462082510000\"\r\n },\r\n \"amtEnabledInBIOS\": true,\r\n \"meInterfaceVersion\": \"16.1.25.2124\",\r\n \"dhcpEnabled\": true,\r\n \"certHashes\": [\r\n \"a1b2c3\",\r\n \"d4e5f6\"\r\n ],\r\n \"lmsInstalled\": true,\r\n \"lmsVersion\": \"2410.5.0.0\",\r\n \"osName\": \"linux\",\r\n \"osVersion\": \"6.8.0-51-generic\",\r\n \"osDistro\": \"Ubuntu 24.04 LTS\",\r\n \"cpuModel\": \"Intel(R) Core(TM) Ultra 7 165H\",\r\n \"osIpAddress\": \"10.49.76.163\",\r\n \"ethernetAdapterCount\": 2,\r\n \"monitorConnected\": true,\r\n \"ieee8021xEnabled\": false\r\n }\r\n}", + "raw": "{\r\n \"guid\": \"143e4567-e89b-12d3-a456-426614174000\",\r\n \"friendlyName\": \"friendlyName\",\r\n \"hostname\": \"hostname\",\r\n \"tags\": [],\r\n \"mpsusername\": \"admin\",\r\n \"deviceInfo\": {\r\n \"fwVersion\": \"16.1.30\",\r\n \"fwBuild\": \"3400\",\r\n \"fwSku\": \"11\",\r\n \"discovered\": true,\r\n \"firstDiscovered\": \"2026-05-20T00:00:00Z\",\r\n \"currentMode\": \"Admin\",\r\n \"features\": \"SOL,IDER,KVM\",\r\n \"ipAddress\": \"10.0.0.12\",\r\n \"lastSynced\": \"2026-05-21T00:00:00Z\",\r\n \"tlsMode\": \"TLS 1.2\",\r\n \"upid\": {\r\n \"oemPlatformIdType\": \"Not Set (0)\",\r\n \"oemId\": \"\",\r\n \"csmeId\": \"4A45A39C5ED9462082510000\"\r\n },\r\n \"amtEnabledInBIOS\": true,\r\n \"meInterfaceVersion\": \"16.1.25.2124\",\r\n \"dhcpEnabled\": true,\r\n \"certHashes\": [\r\n \"a1b2c3\",\r\n \"d4e5f6\"\r\n ],\r\n \"lmsInstalled\": true,\r\n \"lmsVersion\": \"2410.5.0.0\",\r\n \"osName\": \"linux\",\r\n \"osVersion\": \"6.8.0-51-generic\",\r\n \"osDistro\": \"Ubuntu 24.04 LTS\",\r\n \"cpuModel\": \"Intel(R) Core(TM) Ultra 7 165H\",\r\n \"osIpAddress\": \"10.49.76.163\",\r\n \"ethernetAdapterCount\": 2,\r\n \"monitorConnected\": true,\r\n \"ieee8021xEnabled\": false\r\n }\r\n}", "options": { "raw": { "language": "json" diff --git a/internal/controller/httpapi/v1/devices_test.go b/internal/controller/httpapi/v1/devices_test.go index 3138731b4..6b2e967e8 100644 --- a/internal/controller/httpapi/v1/devices_test.go +++ b/internal/controller/httpapi/v1/devices_test.go @@ -528,15 +528,16 @@ func TestDevicesInsertAcceptsFullDeviceInfo(t *testing.T) { GUID: testDeviceGUID, Hostname: "test-device", DeviceInfo: &dto.DeviceInfo{ - FWVersion: "16.1.30", - FWBuild: "3400", - FWSku: "11", - Discovered: &discovered, - CurrentMode: "Admin", - Features: "SOL,IDER,KVM", - IPAddress: "10.0.0.12", - LastUpdated: &timeNow, - TLSMode: "TLS 1.2", + FWVersion: "16.1.30", + FWBuild: "3400", + FWSku: "11", + Discovered: &discovered, + CurrentMode: "Admin", + Features: "SOL,IDER,KVM", + IPAddress: "10.0.0.12", + FirstDiscovered: &timeNow, + LastSynced: &timeNow, + TLSMode: "TLS 1.2", UPID: map[string]json.RawMessage{ "oemPlatformIdType": json.RawMessage(`"Not Set (0)"`), "oemId": json.RawMessage(`""`), @@ -576,7 +577,8 @@ func TestDevicesInsertAcceptsFullDeviceInfo(t *testing.T) { "currentMode":"Admin", "features":"SOL,IDER,KVM", "ipAddress":"10.0.0.12", - "lastUpdated":"` + timeNow.Format(time.RFC3339Nano) + `", + "firstDiscovered":"` + timeNow.Format(time.RFC3339Nano) + `", + "lastSynced":"` + timeNow.Format(time.RFC3339Nano) + `", "tlsMode":"TLS 1.2", "upid":{ "oemPlatformIdType":"Not Set (0)", diff --git a/internal/controller/openapi/devices.go b/internal/controller/openapi/devices.go index 042a8076f..7252c5b24 100644 --- a/internal/controller/openapi/devices.go +++ b/internal/controller/openapi/devices.go @@ -239,7 +239,8 @@ func (f *FuegoAdapter) getDeviceByID(_ fuego.ContextNoBody) (dto.Device, error) } func exampleDeviceInfo() *dto.DeviceInfo { - lastUpdated := time.Date(2026, 5, 21, 0, 0, 0, 0, time.UTC) + firstDiscovered := time.Date(2026, 5, 20, 0, 0, 0, 0, time.UTC) + lastSynced := time.Date(2026, 5, 21, 0, 0, 0, 0, time.UTC) lmsInstalled := true amtEnabledInBIOS := true dhcpEnabled := true @@ -251,10 +252,11 @@ func exampleDeviceInfo() *dto.DeviceInfo { FWVersion: "16.1.30", FWBuild: "3400", FWSku: "11", + FirstDiscovered: &firstDiscovered, CurrentMode: "Admin", Features: "SOL,IDER,KVM", IPAddress: "10.0.0.12", - LastUpdated: &lastUpdated, + LastSynced: &lastSynced, LMSInstalled: &lmsInstalled, LMSVersion: "2410.5.0.0", TLSMode: "TLS 1.2", diff --git a/internal/entity/dto/v1/device.go b/internal/entity/dto/v1/device.go index a65587ef7..c759649b1 100644 --- a/internal/entity/dto/v1/device.go +++ b/internal/entity/dto/v1/device.go @@ -42,10 +42,11 @@ type DeviceInfo struct { FWBuild string `json:"fwBuild"` FWSku string `json:"fwSku"` Discovered *bool `json:"discovered,omitempty"` + FirstDiscovered *time.Time `json:"firstDiscovered,omitempty"` CurrentMode string `json:"currentMode"` Features string `json:"features"` IPAddress string `json:"ipAddress"` - LastUpdated *time.Time `json:"lastUpdated,omitempty"` + LastSynced *time.Time `json:"lastSynced,omitempty"` LMSInstalled *bool `json:"lmsInstalled,omitempty"` LMSVersion string `json:"lmsVersion,omitempty"` TLSMode string `json:"tlsMode,omitempty"` @@ -64,6 +65,29 @@ type DeviceInfo struct { IEEE8021XEnabled *bool `json:"ieee8021xEnabled,omitempty"` } +// UnmarshalJSON implements custom JSON deserialization to support backwards compatibility +// for the lastUpdated -> lastSynced field rename. Existing clients may send the old +// "lastUpdated" key; this method migrates it to the new "lastSynced" field if present. +func (d *DeviceInfo) UnmarshalJSON(data []byte) error { + type Alias DeviceInfo + + type deviceInfoCompat struct { + *Alias + LegacyLastUpdated *time.Time `json:"lastUpdated,omitempty"` + } + + aux := deviceInfoCompat{Alias: (*Alias)(d)} + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + + if d.LastSynced == nil && aux.LegacyLastUpdated != nil { + d.LastSynced = aux.LegacyLastUpdated + } + + return nil +} + type Explorer struct { XMLInput string `json:"xmlInput"` XMLOutput string `json:"xmlOutput"` diff --git a/internal/entity/dto/v1/device_test.go b/internal/entity/dto/v1/device_test.go index ccfaac18d..b32ffc6e5 100644 --- a/internal/entity/dto/v1/device_test.go +++ b/internal/entity/dto/v1/device_test.go @@ -18,18 +18,20 @@ func TestDeviceInfoJSONRoundTrip(t *testing.T) { ethernetAdapterCount := 2 monitorConnected := true ieee8021xEnabled := false - lastUpdated := time.Date(2026, 5, 21, 0, 0, 0, 0, time.UTC) + firstDiscovered := time.Date(2026, 5, 20, 0, 0, 0, 0, time.UTC) + lastSynced := time.Date(2026, 5, 21, 0, 0, 0, 0, time.UTC) info := DeviceInfo{ - FWVersion: "16.1.30", - FWBuild: "3400", - FWSku: "11", - Discovered: &discovered, - CurrentMode: "Admin", - Features: "SOL,IDER,KVM", - IPAddress: "10.0.0.12", - LastUpdated: &lastUpdated, - TLSMode: "TLS 1.2", + FWVersion: "16.1.30", + FWBuild: "3400", + FWSku: "11", + Discovered: &discovered, + FirstDiscovered: &firstDiscovered, + CurrentMode: "Admin", + Features: "SOL,IDER,KVM", + IPAddress: "10.0.0.12", + LastSynced: &lastSynced, + TLSMode: "TLS 1.2", UPID: map[string]json.RawMessage{ "oemPlatformIdType": json.RawMessage(`"Not Set (0)"`), "oemId": json.RawMessage(`""`), @@ -65,4 +67,20 @@ func TestDeviceInfoJSONRoundTrip(t *testing.T) { require.Equal(t, *info.Discovered, *decoded.Discovered) require.NotNil(t, decoded.LMSInstalled) require.Equal(t, *info.LMSInstalled, *decoded.LMSInstalled) + require.NotNil(t, decoded.FirstDiscovered) + require.Equal(t, *info.FirstDiscovered, *decoded.FirstDiscovered) + require.NotNil(t, decoded.LastSynced) + require.Equal(t, *info.LastSynced, *decoded.LastSynced) +} + +func TestDeviceInfoJSONLegacyLastUpdatedMapsToLastSynced(t *testing.T) { + t.Parallel() + + legacy := time.Date(2026, 5, 21, 0, 0, 0, 0, time.UTC) + payload := []byte(`{"fwVersion":"16.1.30","lastUpdated":"` + legacy.Format(time.RFC3339Nano) + `"}`) + + var decoded DeviceInfo + require.NoError(t, json.Unmarshal(payload, &decoded)) + require.NotNil(t, decoded.LastSynced) + require.Equal(t, legacy, *decoded.LastSynced) } diff --git a/internal/usecase/devices/repo_test.go b/internal/usecase/devices/repo_test.go index 9b5ede052..c5ff390b5 100644 --- a/internal/usecase/devices/repo_test.go +++ b/internal/usecase/devices/repo_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "testing" + "time" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" @@ -759,6 +760,9 @@ func TestUpdatePartial(t *testing.T) { MEBXPassword: ptr("encrypted-mebx"), } + firstDiscovered := time.Date(2026, 5, 20, 0, 0, 0, 0, time.UTC) + lastSynced := time.Date(2026, 5, 21, 0, 0, 0, 0, time.UTC) + // Client sends guid, a new hostname, and a partial deviceInfo update. discovered := true incoming := &dto.Device{ @@ -766,26 +770,30 @@ func TestUpdatePartial(t *testing.T) { TenantID: "tenant-id-456", Hostname: "new-hostname", DeviceInfo: &dto.DeviceInfo{ - FWVersion: "16.1.30", - Discovered: &discovered, - IPAddress: "10.0.0.55", - LMSInstalled: boolPtr(true), + FWVersion: "16.1.30", + Discovered: &discovered, + FirstDiscovered: &firstDiscovered, + IPAddress: "10.0.0.55", + LastSynced: &lastSynced, + LMSInstalled: boolPtr(true), }, } fields := map[string]bool{ - "guid": true, - "tenantId": true, - "hostname": true, - "deviceinfo": true, - "deviceinfo.fwversion": true, - "deviceinfo.discovered": true, - "deviceinfo.ipaddress": true, - "deviceinfo.lmsinstalled": true, + "guid": true, + "tenantId": true, + "hostname": true, + "deviceinfo": true, + "deviceinfo.fwversion": true, + "deviceinfo.discovered": true, + "deviceinfo.firstdiscovered": true, + "deviceinfo.ipaddress": true, + "deviceinfo.lastsynced": true, + "deviceinfo.lmsinstalled": true, } // After merge + dtoToEntity (MockCrypto re-encrypts plaintext to "encrypted"): // Only fields without omitempty tag are included in JSON for zero values. - expectedDeviceInfoJSON := `{"fwVersion":"16.1.30","fwBuild":"3400","fwSku":"","discovered":true,"currentMode":"","features":"","ipAddress":"10.0.0.55","lmsInstalled":true}` + expectedDeviceInfoJSON := `{"fwVersion":"16.1.30","fwBuild":"3400","fwSku":"","discovered":true,"firstDiscovered":"2026-05-20T00:00:00Z","currentMode":"","features":"","ipAddress":"10.0.0.55","lastSynced":"2026-05-21T00:00:00Z","lmsInstalled":true}` expectedEntity := &entity.Device{ GUID: "device-guid-123", TenantID: "tenant-id-456", @@ -804,11 +812,13 @@ func TestUpdatePartial(t *testing.T) { TenantID: "tenant-id-456", Hostname: "new-hostname", DeviceInfo: &dto.DeviceInfo{ - FWVersion: "16.1.30", - FWBuild: "3400", - Discovered: boolPtr(true), - IPAddress: "10.0.0.55", - LMSInstalled: boolPtr(true), + FWVersion: "16.1.30", + FWBuild: "3400", + Discovered: boolPtr(true), + FirstDiscovered: &firstDiscovered, + IPAddress: "10.0.0.55", + LastSynced: &lastSynced, + LMSInstalled: boolPtr(true), }, Tags: []string{"lab", "floor-2"}, MPSUsername: "admin", @@ -886,6 +896,137 @@ func TestUpdatePartial(t *testing.T) { }) } +// TestUpdatePreservesFirstDiscovered verifies firstDiscovered and discovered are +// immutable once set: a client PATCH cannot overwrite the stored values, while +// lastSynced still advances. +func TestUpdatePreservesFirstDiscovered(t *testing.T) { + t.Parallel() + + original := time.Date(2026, 5, 20, 0, 0, 0, 0, time.UTC) + + // Stored record already discovered on 2026-05-20. + existing := &entity.Device{ + GUID: "device-guid-123", + TenantID: "tenant-id-456", + DeviceInfo: `{"discovered":false,"firstDiscovered":"2026-05-20T00:00:00Z","lastSynced":"2026-05-20T00:00:00Z"}`, + Password: "encrypted-amt", + } + + // Client tries to change firstDiscovered/discovered and bumps lastSynced. + tampered := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) + newSync := time.Date(2026, 5, 25, 0, 0, 0, 0, time.UTC) + discoveredTrue := true + incoming := &dto.Device{ + GUID: "device-guid-123", + TenantID: "tenant-id-456", + DeviceInfo: &dto.DeviceInfo{ + Discovered: &discoveredTrue, + FirstDiscovered: &tampered, + LastSynced: &newSync, + }, + } + fields := map[string]bool{ + "deviceinfo": true, + "deviceinfo.discovered": true, + "deviceinfo.firstdiscovered": true, + "deviceinfo.lastsynced": true, + } + + useCase, repo, management := devicesTest(t) + + repo.EXPECT(). + GetByID(context.Background(), "device-guid-123", "tenant-id-456"). + Return(existing, nil) + repo.EXPECT(). + Update(context.Background(), gomock.Any()). + DoAndReturn(func(_ context.Context, actualEntity *entity.Device) (bool, error) { + var info dto.DeviceInfo + require.NoError(t, json.Unmarshal([]byte(actualEntity.DeviceInfo), &info)) + + // Immutable fields keep their stored values. + require.NotNil(t, info.FirstDiscovered) + require.Equal(t, original, *info.FirstDiscovered) + require.NotNil(t, info.Discovered) + require.False(t, *info.Discovered) + + // lastSynced advances. + require.NotNil(t, info.LastSynced) + require.Equal(t, newSync, *info.LastSynced) + + return true, nil + }) + repo.EXPECT(). + GetByID(context.Background(), "device-guid-123", "tenant-id-456"). + Return(existing, nil) + management.EXPECT().DestroyWsmanClient(gomock.Any()) + + _, err := useCase.Update(context.Background(), incoming, fields) + require.NoError(t, err) +} + +// TestUpdatePreservesFirstDiscoveredWholesale covers the backward-compat path where +// the caller sends only a top-level "deviceinfo" key (no nested deviceinfo.* keys), +// which replaces DeviceInfo wholesale. firstDiscovered/discovered must still be kept +// from the stored record. +func TestUpdatePreservesFirstDiscoveredWholesale(t *testing.T) { + t.Parallel() + + original := time.Date(2026, 5, 20, 0, 0, 0, 0, time.UTC) + + existing := &entity.Device{ + GUID: "device-guid-123", + TenantID: "tenant-id-456", + DeviceInfo: `{"discovered":false,"firstDiscovered":"2026-05-20T00:00:00Z","lastSynced":"2026-05-20T00:00:00Z"}`, + Password: "encrypted-amt", + } + + tampered := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) + newSync := time.Date(2026, 5, 25, 0, 0, 0, 0, time.UTC) + discoveredTrue := true + incoming := &dto.Device{ + GUID: "device-guid-123", + TenantID: "tenant-id-456", + DeviceInfo: &dto.DeviceInfo{ + Discovered: &discoveredTrue, + FirstDiscovered: &tampered, + LastSynced: &newSync, + }, + } + // Only the top-level key — no nested deviceinfo.* keys. + fields := map[string]bool{"deviceinfo": true} + + useCase, repo, management := devicesTest(t) + + repo.EXPECT(). + GetByID(context.Background(), "device-guid-123", "tenant-id-456"). + Return(existing, nil) + repo.EXPECT(). + Update(context.Background(), gomock.Any()). + DoAndReturn(func(_ context.Context, actualEntity *entity.Device) (bool, error) { + var info dto.DeviceInfo + require.NoError(t, json.Unmarshal([]byte(actualEntity.DeviceInfo), &info)) + + // Immutable fields keep their stored values even on wholesale replace. + require.NotNil(t, info.FirstDiscovered) + require.Equal(t, original, *info.FirstDiscovered) + require.NotNil(t, info.Discovered) + require.False(t, *info.Discovered) + + // Non-immutable field is taken from the incoming payload. + require.NotNil(t, info.LastSynced) + require.Equal(t, newSync, *info.LastSynced) + + return true, nil + }) + repo.EXPECT(). + GetByID(context.Background(), "device-guid-123", "tenant-id-456"). + Return(existing, nil) + management.EXPECT().DestroyWsmanClient(gomock.Any()) + + _, err := useCase.Update(context.Background(), incoming, fields) + require.NoError(t, err) +} + func TestUpdateConnectionStatus(t *testing.T) { t.Parallel() diff --git a/internal/usecase/devices/usecase.go b/internal/usecase/devices/usecase.go index dcd34b431..711a39dc4 100644 --- a/internal/usecase/devices/usecase.go +++ b/internal/usecase/devices/usecase.go @@ -166,11 +166,13 @@ var deviceInfoFieldSetters = map[string]func(dst, src *dto.DeviceInfo){ "fwversion": func(dst, src *dto.DeviceInfo) { dst.FWVersion = src.FWVersion }, "fwbuild": func(dst, src *dto.DeviceInfo) { dst.FWBuild = src.FWBuild }, "fwsku": func(dst, src *dto.DeviceInfo) { dst.FWSku = src.FWSku }, - "discovered": func(dst, src *dto.DeviceInfo) { dst.Discovered = src.Discovered }, + "discovered": setDiscoveredOnce, + "firstdiscovered": setFirstDiscoveredOnce, "currentmode": func(dst, src *dto.DeviceInfo) { dst.CurrentMode = src.CurrentMode }, "features": func(dst, src *dto.DeviceInfo) { dst.Features = src.Features }, "ipaddress": func(dst, src *dto.DeviceInfo) { dst.IPAddress = src.IPAddress }, - "lastupdated": func(dst, src *dto.DeviceInfo) { dst.LastUpdated = src.LastUpdated }, + "lastupdated": func(dst, src *dto.DeviceInfo) { dst.LastSynced = src.LastSynced }, // legacy alias for lastsynced + "lastsynced": func(dst, src *dto.DeviceInfo) { dst.LastSynced = src.LastSynced }, "tlsmode": func(dst, src *dto.DeviceInfo) { dst.TLSMode = src.TLSMode }, "upid": func(dst, src *dto.DeviceInfo) { dst.UPID = src.UPID }, "amtenabledinbios": func(dst, src *dto.DeviceInfo) { dst.AMTEnabledInBIOS = src.AMTEnabledInBIOS }, @@ -189,6 +191,20 @@ var deviceInfoFieldSetters = map[string]func(dst, src *dto.DeviceInfo){ "ieee8021xenabled": func(dst, src *dto.DeviceInfo) { dst.IEEE8021XEnabled = src.IEEE8021XEnabled }, } +// firstDiscovered and discovered are set once at initial discovery and are immutable +// thereafter: dst is the stored record, so keep its existing value if already present. +func setFirstDiscoveredOnce(dst, src *dto.DeviceInfo) { + if dst.FirstDiscovered == nil { + dst.FirstDiscovered = src.FirstDiscovered + } +} + +func setDiscoveredOnce(dst, src *dto.DeviceInfo) { + if dst.Discovered == nil { + dst.Discovered = src.Discovered + } +} + func mergeDeviceFields(dst, src *dto.Device, fields map[string]bool) { for key := range fields { if key == deviceInfoFieldKey || strings.HasPrefix(key, deviceInfoFieldPrefix) { @@ -232,8 +248,28 @@ func mergeDeviceInfo(dst, src *dto.Device, fields map[string]bool) { } if !hasNestedFields { - // Backward compatibility for callers that only send top-level field maps. - dst.DeviceInfo = src.DeviceInfo + // Backward compatibility for callers that only send a top-level "deviceinfo" + // key: replace wholesale, but keep the stored write-once fields. + replaceDeviceInfoPreservingImmutable(dst, src.DeviceInfo) + } +} + +// replaceDeviceInfoPreservingImmutable replaces dst.DeviceInfo with src, keeping the +// stored write-once fields (firstDiscovered/discovered) so they stay immutable. +func replaceDeviceInfoPreservingImmutable(dst *dto.Device, src *dto.DeviceInfo) { + stored := dst.DeviceInfo + dst.DeviceInfo = src + + if stored == nil { + return + } + + if stored.FirstDiscovered != nil { + dst.DeviceInfo.FirstDiscovered = stored.FirstDiscovered + } + + if stored.Discovered != nil { + dst.DeviceInfo.Discovered = stored.Discovered } }