@@ -191,3 +191,197 @@ describe('K8sAggregatorEditor – aggregator ID pattern validation', () => {
191191 } ) ;
192192 } ) ;
193193} ) ;
194+
195+ describe ( 'K8sAggregatorEditor – round-trips fields the form has no editor for' , ( ) => {
196+ // Metadata mapping in its fuller shape: an exact-key rule and a prefix rule carrying namespace + key. These
197+ // must survive a load→save round-trip verbatim even though the form cannot edit them.
198+ const METADATA_MAPPING = [
199+ { resourceType : 'NODE' , entryType : 'LABEL' , sourceKey : 'topology.kubernetes.io/zone' } ,
200+ {
201+ resourceType : 'POD' ,
202+ entryType : 'ANNOTATION' ,
203+ sourceKeyPrefix : 'topology.kubernetes.io/' ,
204+ metadataNamespace : 'envoy.lb' ,
205+ metadataKey : 'zone' ,
206+ } ,
207+ ] ;
208+ // A watcher carrying distinctEndpoint + metadataMapping, plus an aggregator-level policy — all fields the form
209+ // does not model. Before the fix, saving from the form silently dropped every one of them.
210+ const ADVANCED_CONTENT = {
211+ policy : { overprovisioningFactor : 140 } ,
212+ localityLbEndpoints : [
213+ {
214+ watcher : {
215+ serviceName : 'my-service' ,
216+ kubeconfig : { controlPlaneUrl : 'https://kubernetes.default.svc' } ,
217+ additionalProperties : { nodeIpLabel : 'private-ip' } ,
218+ distinctEndpoint : true ,
219+ metadataMapping : METADATA_MAPPING ,
220+ } ,
221+ } ,
222+ ] ,
223+ } ;
224+
225+ let mockUpdate : jest . Mock ;
226+
227+ const setupMocks = ( content : unknown ) => {
228+ mockUpdate = jest . fn ( ) . mockReturnValue ( { unwrap : ( ) => Promise . resolve ( { } ) } ) ;
229+ jest
230+ . mocked ( xdsApiSlice . useCreateK8sAggregatorMutation )
231+ . mockReturnValue ( [
232+ jest . fn ( ) . mockReturnValue ( { unwrap : ( ) => Promise . resolve ( { } ) } ) ,
233+ { isLoading : false } ,
234+ ] as any ) ;
235+ jest
236+ . mocked ( xdsApiSlice . useUpdateK8sAggregatorMutation )
237+ . mockReturnValue ( [ mockUpdate , { isLoading : false } ] as any ) ;
238+ jest
239+ . mocked ( xdsApiSlice . useDeleteK8sAggregatorMutation )
240+ . mockReturnValue ( [ jest . fn ( ) , { isLoading : false } ] as any ) ;
241+ jest
242+ . mocked ( xdsApiSlice . usePreviewK8sAggregatorMutation )
243+ . mockReturnValue ( [ jest . fn ( ) , { isLoading : false } ] as any ) ;
244+ jest . mocked ( xdsApiSlice . useGetK8sAggregatorQuery ) . mockReturnValue ( {
245+ data : { content : jsYaml . dump ( content ) } ,
246+ isLoading : false ,
247+ error : undefined ,
248+ } as any ) ;
249+ jest . mocked ( xdsApiSlice . useListCredentialsQuery ) . mockReturnValue ( { data : [ ] , error : null } as any ) ;
250+ } ;
251+
252+ beforeEach ( ( ) => {
253+ setupMocks ( ADVANCED_CONTENT ) ;
254+ } ) ;
255+
256+ const savedBody = ( ) => jsYaml . load ( mockUpdate . mock . calls [ 0 ] [ 0 ] . body ) as any ;
257+ const savedWatcher = ( ) => savedBody ( ) . localityLbEndpoints [ 0 ] . watcher ;
258+
259+ it ( 'preserves distinctEndpoint, metadataMapping, and policy across an unrelated edit' , async ( ) => {
260+ const user = userEvent . setup ( ) ;
261+ renderWithProviders ( < K8sAggregatorEditor group = "foo" id = "my-agg" isNew = { false } /> ) ;
262+ await waitFor ( ( ) => expect ( screen . getByDisplayValue ( 'my-agg' ) ) . toBeInTheDocument ( ) ) ;
263+
264+ await user . click ( screen . getByRole ( 'button' , { name : / ^ e d i t $ / i } ) ) ;
265+ // Make a genuinely unrelated change (rename the service) so the preserved fields ride through a real edit.
266+ const serviceInput = screen . getByDisplayValue ( 'my-service' ) ;
267+ await user . clear ( serviceInput ) ;
268+ await user . type ( serviceInput , 'renamed-service' ) ;
269+ await user . click ( screen . getByRole ( 'button' , { name : / ^ s a v e $ / i } ) ) ;
270+
271+ await waitFor ( ( ) => expect ( mockUpdate ) . toHaveBeenCalled ( ) ) ;
272+ const watcher = savedWatcher ( ) ;
273+ expect ( watcher . serviceName ) . toBe ( 'renamed-service' ) ;
274+ expect ( watcher . distinctEndpoint ) . toBe ( true ) ;
275+ // The full metadata_mapping shape (both rules, all sub-fields) is preserved verbatim.
276+ expect ( watcher . metadataMapping ) . toEqual ( METADATA_MAPPING ) ;
277+ expect ( savedBody ( ) . policy ) . toEqual ( { overprovisioningFactor : 140 } ) ;
278+ } ) ;
279+
280+ it ( 'reflects the stored Distinct endpoint value and lets the user turn it off while preserving metadataMapping' , async ( ) => {
281+ const user = userEvent . setup ( ) ;
282+ renderWithProviders ( < K8sAggregatorEditor group = "foo" id = "my-agg" isNew = { false } /> ) ;
283+ await waitFor ( ( ) => expect ( screen . getByDisplayValue ( 'my-agg' ) ) . toBeInTheDocument ( ) ) ;
284+
285+ expect ( screen . getByRole ( 'checkbox' , { name : / d i s t i n c t e n d p o i n t / i } ) ) . toBeChecked ( ) ;
286+
287+ await user . click ( screen . getByRole ( 'button' , { name : / ^ e d i t $ / i } ) ) ;
288+ await user . click ( screen . getByRole ( 'checkbox' , { name : / d i s t i n c t e n d p o i n t / i } ) ) ;
289+ await user . click ( screen . getByRole ( 'button' , { name : / ^ s a v e $ / i } ) ) ;
290+
291+ await waitFor ( ( ) => expect ( mockUpdate ) . toHaveBeenCalled ( ) ) ;
292+ const watcher = savedWatcher ( ) ;
293+ // Turned off → omitted from the committed body …
294+ expect ( watcher . distinctEndpoint ) . toBeUndefined ( ) ;
295+ // … while the metadata_mapping the form never touched is still preserved.
296+ expect ( watcher . metadataMapping ) . toEqual ( METADATA_MAPPING ) ;
297+ } ) ;
298+
299+ it ( 'writes distinctEndpoint when enabled on an aggregator that lacked it, and emits no policy key' , async ( ) => {
300+ setupMocks ( {
301+ localityLbEndpoints : [
302+ {
303+ watcher : {
304+ serviceName : 'my-service' ,
305+ kubeconfig : { controlPlaneUrl : 'https://kubernetes.default.svc' } ,
306+ } ,
307+ } ,
308+ ] ,
309+ } ) ;
310+ const user = userEvent . setup ( ) ;
311+ renderWithProviders ( < K8sAggregatorEditor group = "foo" id = "my-agg" isNew = { false } /> ) ;
312+ await waitFor ( ( ) => expect ( screen . getByDisplayValue ( 'my-agg' ) ) . toBeInTheDocument ( ) ) ;
313+
314+ expect ( screen . getByRole ( 'checkbox' , { name : / d i s t i n c t e n d p o i n t / i } ) ) . not . toBeChecked ( ) ;
315+
316+ await user . click ( screen . getByRole ( 'button' , { name : / ^ e d i t $ / i } ) ) ;
317+ await user . click ( screen . getByRole ( 'checkbox' , { name : / d i s t i n c t e n d p o i n t / i } ) ) ;
318+ await user . click ( screen . getByRole ( 'button' , { name : / ^ s a v e $ / i } ) ) ;
319+
320+ await waitFor ( ( ) => expect ( mockUpdate ) . toHaveBeenCalled ( ) ) ;
321+ expect ( savedWatcher ( ) . distinctEndpoint ) . toBe ( true ) ;
322+ // An aggregator that had no policy must not gain one (no diff noise / no schema change).
323+ expect ( savedBody ( ) ) . not . toHaveProperty ( 'policy' ) ;
324+ } ) ;
325+
326+ it ( 'keeps each metadataMapping with its own watcher after removing another watcher' , async ( ) => {
327+ setupMocks ( {
328+ localityLbEndpoints : [
329+ {
330+ watcher : {
331+ serviceName : 'svc-1' ,
332+ kubeconfig : { controlPlaneUrl : 'https://kubernetes.default.svc' } ,
333+ distinctEndpoint : true ,
334+ metadataMapping : [ { resourceType : 'NODE' , entryType : 'LABEL' , sourceKey : 'zone-1' } ] ,
335+ } ,
336+ } ,
337+ {
338+ watcher : {
339+ serviceName : 'svc-2' ,
340+ kubeconfig : { controlPlaneUrl : 'https://kubernetes.default.svc' } ,
341+ metadataMapping : [ { resourceType : 'POD' , entryType : 'ANNOTATION' , sourceKey : 'rack-2' } ] ,
342+ } ,
343+ } ,
344+ ] ,
345+ } ) ;
346+ const user = userEvent . setup ( ) ;
347+ renderWithProviders ( < K8sAggregatorEditor group = "foo" id = "my-agg" isNew = { false } /> ) ;
348+ await waitFor ( ( ) => expect ( screen . getByDisplayValue ( 'svc-1' ) ) . toBeInTheDocument ( ) ) ;
349+
350+ await user . click ( screen . getByRole ( 'button' , { name : / ^ e d i t $ / i } ) ) ;
351+ // Remove the first watcher; the second must keep its own mapping and not inherit the first's.
352+ await user . click ( screen . getAllByRole ( 'button' , { name : / ^ r e m o v e $ / i } ) [ 0 ] ) ;
353+ await user . click ( screen . getByRole ( 'button' , { name : / ^ s a v e $ / i } ) ) ;
354+
355+ await waitFor ( ( ) => expect ( mockUpdate ) . toHaveBeenCalled ( ) ) ;
356+ const endpoints = savedBody ( ) . localityLbEndpoints ;
357+ expect ( endpoints ) . toHaveLength ( 1 ) ;
358+ expect ( endpoints [ 0 ] . watcher . serviceName ) . toBe ( 'svc-2' ) ;
359+ expect ( endpoints [ 0 ] . watcher . metadataMapping ) . toEqual ( [
360+ { resourceType : 'POD' , entryType : 'ANNOTATION' , sourceKey : 'rack-2' } ,
361+ ] ) ;
362+ // svc-2 had no distinctEndpoint; removing svc-1 must not leak svc-1's onto it.
363+ expect ( endpoints [ 0 ] . watcher . distinctEndpoint ) . toBeUndefined ( ) ;
364+ } ) ;
365+
366+ it ( 'surfaces preserved metadata mapping rules as a read-only note' , async ( ) => {
367+ renderWithProviders ( < K8sAggregatorEditor group = "foo" id = "my-agg" isNew = { false } /> ) ;
368+ await waitFor ( ( ) => expect ( screen . getByDisplayValue ( 'my-agg' ) ) . toBeInTheDocument ( ) ) ;
369+ expect ( screen . getByText ( / 2 m e t a d a t a m a p p i n g r u l e / i) ) . toBeInTheDocument ( ) ;
370+ } ) ;
371+
372+ it ( 'shows no metadata mapping note when the watcher has none' , async ( ) => {
373+ setupMocks ( {
374+ localityLbEndpoints : [
375+ {
376+ watcher : {
377+ serviceName : 'my-service' ,
378+ kubeconfig : { controlPlaneUrl : 'https://kubernetes.default.svc' } ,
379+ } ,
380+ } ,
381+ ] ,
382+ } ) ;
383+ renderWithProviders ( < K8sAggregatorEditor group = "foo" id = "my-agg" isNew = { false } /> ) ;
384+ await waitFor ( ( ) => expect ( screen . getByDisplayValue ( 'my-agg' ) ) . toBeInTheDocument ( ) ) ;
385+ expect ( screen . queryByText ( / m e t a d a t a m a p p i n g r u l e / i) ) . not . toBeInTheDocument ( ) ;
386+ } ) ;
387+ } ) ;
0 commit comments