diff --git a/frontend/src/__tests__/settings.test.ts b/frontend/src/__tests__/settings.test.ts index 9095a769..c6c5c5e6 100644 --- a/frontend/src/__tests__/settings.test.ts +++ b/frontend/src/__tests__/settings.test.ts @@ -82,12 +82,16 @@ describe('Settings Module', () => { + + + @@ -108,11 +112,11 @@ describe('Settings Module', () => { (document.getElementById('setting-default-payment') as HTMLSelectElement).value = 'all-upfront'; // Mirror the same baseline onto every per-service select so the cascade // diff only reports genuinely-changed rows. - ['aws-ec2-term','aws-rds-term','aws-elasticache-term','aws-opensearch-term','aws-redshift-term','aws-savingsplans-term','azure-vm-term','azure-sql-term','azure-cosmos-term','gcp-compute-term','gcp-sql-term'].forEach(id => { + ['aws-ec2-term','aws-rds-term','aws-elasticache-term','aws-opensearch-term','aws-redshift-term','aws-savingsplans-term','aws-sagemaker-term','azure-vm-term','azure-sql-term','azure-cosmos-term','gcp-compute-term','gcp-sql-term'].forEach(id => { const el = document.getElementById(id) as HTMLSelectElement | null; if (el) el.value = '3'; }); - ['aws-ec2-payment','aws-rds-payment','aws-elasticache-payment','aws-opensearch-payment','aws-redshift-payment','aws-savingsplans-payment'].forEach(id => { + ['aws-ec2-payment','aws-rds-payment','aws-elasticache-payment','aws-opensearch-payment','aws-redshift-payment','aws-savingsplans-payment','aws-sagemaker-payment'].forEach(id => { const el = document.getElementById(id) as HTMLSelectElement | null; if (el) el.value = 'all-upfront'; }); @@ -506,7 +510,53 @@ describe('Settings Module', () => { expect(api.updateConfig).toHaveBeenCalled(); }); - test('calls updateServiceConfig once per service field (15 calls)', async () => { + // Issue #22: SageMaker has its own SP type, so it gets a dedicated + // purchasing-defaults card. Verify the card exists in the canonical + // index.html (so the deployed UI actually renders it) and that the + // save flow round-trips edits to the per-service config endpoint. + // Lambda is intentionally absent — it has no standalone SP product. + test('SageMaker card present in index.html with term and payment selects (issue #22)', () => { + const fs = require('fs') as typeof import('fs'); + const path = require('path') as typeof import('path'); + const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf-8'); + expect(html).toMatch(/
\s*SageMaker Savings Plans\s*<\/h5>/); + expect(html).toMatch(/id="aws-sagemaker-term"/); + expect(html).toMatch(/id="aws-sagemaker-payment"/); + }); + + test('no Lambda card in index.html — Lambda has no standalone SP, only Compute SP (issue #22)', () => { + const fs = require('fs') as typeof import('fs'); + const path = require('path') as typeof import('path'); + const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf-8'); + // Guard against accidental re-introduction of a Lambda-specific card. + expect(html).not.toMatch(/
\s*Lambda Savings Plans\s*<\/h5>/); + expect(html).not.toMatch(/id="aws-lambda-term"/); + expect(html).not.toMatch(/id="aws-lambda-payment"/); + }); + + test('saveGlobalSettings sends per-service SageMaker term and payment (issue #22)', async () => { + (api.updateConfig as jest.Mock).mockResolvedValue({}); + (api.updateServiceConfig as jest.Mock).mockClear().mockResolvedValue(undefined); + window.alert = jest.fn(); + + // User pins SageMaker to 1yr / no-upfront while leaving the global + // default at 3yr / all-upfront. + (document.getElementById('aws-sagemaker-term') as HTMLSelectElement).value = '1'; + (document.getElementById('aws-sagemaker-payment') as HTMLSelectElement).value = 'no-upfront'; + + const event = { preventDefault: jest.fn() } as unknown as Event; + await saveGlobalSettings(event); + + const call = (api.updateServiceConfig as jest.Mock).mock.calls.find( + ([provider, service]) => provider === 'aws' && service === 'sagemaker', + ); + expect(call).toBeDefined(); + const cfg = call![2]; + expect(cfg.term).toBe(1); + expect(cfg.payment).toBe('no-upfront'); + }); + + test('calls updateServiceConfig once per service field (16 calls)', async () => { (api.updateConfig as jest.Mock).mockResolvedValue({}); (api.updateServiceConfig as jest.Mock).mockResolvedValue(undefined); window.alert = jest.fn(); @@ -514,8 +564,10 @@ describe('Settings Module', () => { const event = { preventDefault: jest.fn() } as unknown as Event; await saveGlobalSettings(event); - // 6 AWS + 5 Azure (vm, sql, cosmosdb, redis, search) + 4 GCP. - expect(api.updateServiceConfig).toHaveBeenCalledTimes(15); + // 7 AWS (ec2, rds, elasticache, opensearch, redshift, savingsplans, + // sagemaker — last one added per issue #22) + 5 Azure + // (vm, sql, cosmosdb, redis, search) + 4 GCP. + expect(api.updateServiceConfig).toHaveBeenCalledTimes(16); }); }); // end saveGlobalSettings @@ -835,6 +887,21 @@ describe('Settings Module', () => { }, ); + test('SageMaker 3yr keeps "no-upfront" visible (issue #22 — no service-level restriction)', async () => { + (api.getConfig as jest.Mock).mockResolvedValue({ + global: { enabled_providers: ['aws'], default_term: 3, default_payment: 'no-upfront', default_coverage: 80 }, + services: [{ provider: 'aws', service: 'sagemaker', term: 3, payment: 'no-upfront' }], + }); + setupSettingsHandlers(); + await loadGlobalSettings(); + + const payment = document.getElementById('aws-sagemaker-payment') as HTMLSelectElement; + expect(optVisible(payment, 'no-upfront')).toBe(true); + expect(optVisible(payment, 'partial-upfront')).toBe(true); + expect(optVisible(payment, 'all-upfront')).toBe(true); + expect(payment.value).toBe('no-upfront'); + }); + test('propagating global "no-upfront" to all services while term=3 clamps restricted services', async () => { (api.getConfig as jest.Mock).mockResolvedValue({ global: { enabled_providers: ['aws'], default_term: 3, default_payment: 'all-upfront', default_coverage: 80 }, diff --git a/frontend/src/index.html b/frontend/src/index.html index edb424a1..d4fdbb08 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -485,10 +485,16 @@
Redshift Reserved Nodes
Savings Plans
-

Compute (EC2, Fargate, Lambda), EC2 Instance, SageMaker, and Database (RDS) plans share these defaults.

+

Compute (EC2, Fargate, Lambda), EC2 Instance, and Database (RDS) plans share these defaults. SageMaker is its own SP type — see card below.

+
+
SageMaker Savings Plans
+

SageMaker Savings Plans are a distinct SP product (separate from Compute SP). These defaults narrow SageMaker recommendations independently of the umbrella Savings Plans card.

+ + +
diff --git a/frontend/src/settings.ts b/frontend/src/settings.ts index aaf49bf8..b0e81139 100644 --- a/frontend/src/settings.ts +++ b/frontend/src/settings.ts @@ -32,6 +32,13 @@ const SERVICE_FIELDS = [ { provider: 'aws', service: 'opensearch', termId: 'aws-opensearch-term', paymentId: 'aws-opensearch-payment' }, { provider: 'aws', service: 'redshift', termId: 'aws-redshift-term', paymentId: 'aws-redshift-payment' }, { provider: 'aws', service: 'savingsplans', termId: 'aws-savingsplans-term', paymentId: 'aws-savingsplans-payment' }, + // Issue #22: SageMaker has its own SP type (SageMaker Savings Plans), + // so it gets a dedicated card and users can pin term/payment per workload + // instead of sharing the umbrella Savings Plans defaults. Lambda is + // intentionally NOT here — Lambda has no standalone SP product; its + // commitments roll up into Compute Savings Plans, already covered by + // the umbrella card above. + { provider: 'aws', service: 'sagemaker', termId: 'aws-sagemaker-term', paymentId: 'aws-sagemaker-payment' }, { provider: 'azure', service: 'vm', termId: 'azure-vm-term', paymentId: 'azure-vm-payment' }, { provider: 'azure', service: 'sql', termId: 'azure-sql-term', paymentId: 'azure-sql-payment' }, { provider: 'azure', service: 'cosmosdb', termId: 'azure-cosmosdb-term', paymentId: 'azure-cosmosdb-payment' },