From c3d4707f84c694c4b997f956894857fbe4c1dbdc Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 6 Oct 2026 21:56:00 +0200 Subject: [PATCH 1/2] test(settings): pin the coverage snapshot on failed saves and drop a dead ternary --- frontend/src/__tests__/settings.test.ts | 28 +++++++++++++++++++++++++ frontend/src/settings.ts | 4 +--- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/frontend/src/__tests__/settings.test.ts b/frontend/src/__tests__/settings.test.ts index a5cd806ca..0b320eab0 100644 --- a/frontend/src/__tests__/settings.test.ts +++ b/frontend/src/__tests__/settings.test.ts @@ -965,6 +965,34 @@ describe('Settings Module', () => { expect(coverageSentFor('savings-plans-compute')).toBe(20); }); + test('a rejected global PUT leaves the coverage dirty and sends no service PUTs', async () => { + await loadWithCoverage50(); + (document.getElementById('setting-default-coverage') as HTMLInputElement).value = '70'; + (api.updateConfig as jest.Mock).mockRejectedValueOnce(new Error('boom')); + + await saveGlobalSettings({ preventDefault: jest.fn() } as unknown as Event); + + expect(api.updateServiceConfig).not.toHaveBeenCalled(); + expect(isUnsavedChanges()).toBe(true); + }); + + test('an SP card coverage whose service PUT failed stays dirty and is resent', async () => { + await loadWithCoverage50(); + (document.getElementById('aws-savings-plans-compute-coverage') as HTMLInputElement).value = '70'; + (api.updateServiceConfig as jest.Mock).mockImplementation(async (provider: string, svc: string) => { + if (provider === 'aws' && svc === 'savings-plans-compute') throw new Error('sp failed'); + }); + + await saveGlobalSettings({ preventDefault: jest.fn() } as unknown as Event); + expect(isUnsavedChanges()).toBe(true); + + (api.updateServiceConfig as jest.Mock).mockClear().mockResolvedValue(undefined); + await saveGlobalSettings({ preventDefault: jest.fn() } as unknown as Event); + + expect(coverageSentFor('savings-plans-compute')).toBe(70); + expect(isUnsavedChanges()).toBe(false); + }); + test('an empty global coverage field is rejected instead of saving 0', async () => { await loadWithCoverage50(); (document.getElementById('setting-default-coverage') as HTMLInputElement).value = ''; diff --git a/frontend/src/settings.ts b/frontend/src/settings.ts index ba9e6e88d..306426843 100644 --- a/frontend/src/settings.ts +++ b/frontend/src/settings.ts @@ -3591,9 +3591,7 @@ export async function saveGlobalSettings(e: Event): Promise { // controls. Read from the DOM when the card has the controls; fall back // to the base row value (or the global default) otherwise so RI and // Azure/GCP cards continue to inherit the global settings. - let coverage = defaultCoverageChanged - ? settings.default_coverage - : (base?.coverage ?? settings.default_coverage); + let coverage = base?.coverage ?? settings.default_coverage; let enabled = base?.enabled ?? true; if ('coverageId' in field && field.coverageId) { const rawCov = byId(field.coverageId)?.value ?? ''; From feac707f2c0220d303b1c4b8f023ae04cf2c2799 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 6 Oct 2026 21:57:04 +0200 Subject: [PATCH 2/2] fix(settings): keep a coverage edit typed during its pending service PUT --- frontend/src/__tests__/settings.test.ts | 37 +++++++++++++++++++++++++ frontend/src/settings.ts | 7 +++-- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/frontend/src/__tests__/settings.test.ts b/frontend/src/__tests__/settings.test.ts index 0b320eab0..05cd00a4c 100644 --- a/frontend/src/__tests__/settings.test.ts +++ b/frontend/src/__tests__/settings.test.ts @@ -993,6 +993,43 @@ describe('Settings Module', () => { expect(isUnsavedChanges()).toBe(false); }); + test('an SP card coverage typed during its pending PUT is kept and sent by the next save (#576)', async () => { + await loadWithCoverage50(); + const card = document.getElementById('aws-savings-plans-compute-coverage') as HTMLInputElement; + card.value = '60'; + let resolvePut!: () => void; + (api.updateServiceConfig as jest.Mock).mockImplementation((provider: string, svc: string) => + provider === 'aws' && svc === 'savings-plans-compute' + ? new Promise(resolve => { resolvePut = resolve; }) + : Promise.resolve()); + + const firstSave = saveGlobalSettings({ preventDefault: jest.fn() } as unknown as Event); + await new Promise(r => setTimeout(r, 0)); + card.value = '77'; + resolvePut(); + await firstSave; + + expect(coverageSentFor('savings-plans-compute')).toBe(60); + expect(card.value).toBe('77'); + expect(isUnsavedChanges()).toBe(true); + + (api.updateServiceConfig as jest.Mock).mockClear().mockResolvedValue(undefined); + await saveGlobalSettings({ preventDefault: jest.fn() } as unknown as Event); + + expect(coverageSentFor('savings-plans-compute')).toBe(77); + expect(isUnsavedChanges()).toBe(false); + }); + + test('a saved service payment edit leaves nothing unsaved (#576)', async () => { + await loadWithCoverage50(); + (document.getElementById('aws-ec2-payment') as HTMLSelectElement).value = 'partial-upfront'; + expect(isUnsavedChanges()).toBe(true); + + await saveGlobalSettings({ preventDefault: jest.fn() } as unknown as Event); + + expect(isUnsavedChanges()).toBe(false); + }); + test('an empty global coverage field is rejected instead of saving 0', async () => { await loadWithCoverage50(); (document.getElementById('setting-default-coverage') as HTMLInputElement).value = ''; diff --git a/frontend/src/settings.ts b/frontend/src/settings.ts index 306426843..acc06cfa7 100644 --- a/frontend/src/settings.ts +++ b/frontend/src/settings.ts @@ -3634,8 +3634,11 @@ export async function saveGlobalSettings(e: Event): Promise { ]; if ('coverageId' in field && field.coverageId) { const el = byId(field.coverageId); - if (el) el.value = String(coverage); - savedSnapshot[field.coverageId] = getFieldValue(field.coverageId); + // Show the persisted (possibly propagated) value only when the card + // still holds what this Save read; an edit typed during the PUT stays + // dirty against the persisted value and goes out with the next Save. + if (el && getFieldValue(field.coverageId) === sentFields[field.coverageId]) el.value = String(coverage); + savedSnapshot[field.coverageId] = String(coverage); } }); });