Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 72 additions & 5 deletions frontend/src/__tests__/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,12 +82,16 @@ describe('Settings Module', () => {
<select id="aws-opensearch-term"><option value="1">1</option><option value="3">3</option></select>
<select id="aws-redshift-term"><option value="1">1</option><option value="3">3</option></select>
<select id="aws-savingsplans-term"><option value="1">1</option><option value="3">3</option></select>
<!-- Issue #22: SageMaker per-service card (its own SP type). Lambda
intentionally omitted — it has no standalone SP, only Compute SP. -->
<select id="aws-sagemaker-term"><option value="1">1</option><option value="3">3</option></select>
<select id="aws-ec2-payment"><option value="no-upfront">No</option><option value="partial-upfront">Partial</option><option value="all-upfront">All</option></select>
<select id="aws-rds-payment"><option value="no-upfront">No</option><option value="partial-upfront">Partial</option><option value="all-upfront">All</option></select>
<select id="aws-elasticache-payment"><option value="no-upfront">No</option><option value="partial-upfront">Partial</option><option value="all-upfront">All</option></select>
<select id="aws-opensearch-payment"><option value="no-upfront">No</option><option value="partial-upfront">Partial</option><option value="all-upfront">All</option></select>
<select id="aws-redshift-payment"><option value="no-upfront">No</option><option value="partial-upfront">Partial</option><option value="all-upfront">All</option></select>
<select id="aws-savingsplans-payment"><option value="no-upfront">No</option><option value="partial-upfront">Partial</option><option value="all-upfront">All</option></select>
<select id="aws-sagemaker-payment"><option value="no-upfront">No</option><option value="partial-upfront">Partial</option><option value="all-upfront">All</option></select>
<!-- Azure term selects -->
<select id="azure-vm-term"><option value="1">1</option><option value="3">3</option></select>
<select id="azure-sql-term"><option value="1">1</option><option value="3">3</option></select>
Expand All @@ -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';
});
Expand Down Expand Up @@ -506,16 +510,64 @@ 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(/<h5>\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(/<h5>\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();

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

Expand Down Expand Up @@ -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 },
Expand Down
8 changes: 7 additions & 1 deletion frontend/src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -485,10 +485,16 @@ <h5>Redshift Reserved Nodes</h5>
</div>
<div class="service-default-card">
<h5>Savings Plans</h5>
<p class="service-default-hint">Compute (EC2, Fargate, Lambda), EC2 Instance, SageMaker, and Database (RDS) plans share these defaults.</p>
<p class="service-default-hint">Compute (EC2, Fargate, Lambda), EC2 Instance, and Database (RDS) plans share these defaults. SageMaker is its own SP type — see card below.</p>
<label>Term: <select id="aws-savingsplans-term"><option value="1">1 Year</option><option value="3" selected>3 Years</option></select></label>
<label>Payment: <select id="aws-savingsplans-payment"><option value="no-upfront">No Upfront</option><option value="partial-upfront">Partial</option><option value="all-upfront" selected>All Upfront</option></select></label>
</div>
<div class="service-default-card">
<h5>SageMaker Savings Plans</h5>
<p class="service-default-hint">SageMaker Savings Plans are a distinct SP product (separate from Compute SP). These defaults narrow SageMaker recommendations independently of the umbrella Savings Plans card.</p>
<label>Term: <select id="aws-sagemaker-term"><option value="1">1 Year</option><option value="3" selected>3 Years</option></select></label>
<label>Payment: <select id="aws-sagemaker-payment"><option value="no-upfront">No Upfront</option><option value="partial-upfront">Partial</option><option value="all-upfront" selected>All Upfront</option></select></label>
</div>
</div>
</fieldset>

Expand Down
7 changes: 7 additions & 0 deletions frontend/src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down