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
10 changes: 5 additions & 5 deletions frontend/src/__tests__/settings-accounts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -691,7 +691,7 @@ describe('Overrides panel — AWS payment selector', () => {
// Issue #109: Azure override rows now get inline payment editing.
// Azure uses upfront/monthly (not the AWS no-upfront/partial/all-upfront set).
(api.listAccountServiceOverrides as jest.Mock).mockResolvedValue([
{ id: 'o1', account_id: 'acc-1', provider: 'azure', service: 'vm', term: 1, payment: 'upfront' },
{ id: 'o1', account_id: 'acc-1', provider: 'azure', service: 'compute', term: 1, payment: 'upfront' },
]);

const panel = await openOverridesPanel('acc-1');
Expand Down Expand Up @@ -986,7 +986,7 @@ describe('Overrides panel: inline Term/Coverage/Enabled (issue #110)', () => {
// Issue #109: Azure override rows now have inline editing on all fields,
// same as AWS rows after issue #110.
(api.listAccountServiceOverrides as jest.Mock).mockResolvedValue([
{ id: 'o1', account_id: 'acc-1', provider: 'azure', service: 'vm', term: 1, coverage: 80 },
{ id: 'o1', account_id: 'acc-1', provider: 'azure', service: 'compute', term: 1, coverage: 80 },
]);

const panel = await openOverridesPanel('acc-1');
Expand Down Expand Up @@ -1215,9 +1215,9 @@ describe('Create-override modal', () => {
// Service dropdown lists Azure services, not AWS services.
const svcSel = document.getElementById('override-service') as HTMLSelectElement;
const svcValues = Array.from(svcSel.options).map(o => o.value);
expect(svcValues).toContain('vm');
expect(svcValues).toContain('sql');
expect(svcValues).toContain('cosmosdb');
expect(svcValues).toContain('compute');
expect(svcValues).toContain('relational-db');
expect(svcValues).toContain('nosql');
expect(svcValues).not.toContain('ec2');
expect(svcValues).not.toContain('rds');
});
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/__tests__/settings-gcp-storage-removed.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ describe('GCP storage removal (issue #543)', () => {
await new Promise(r => setTimeout(r, 0));
});

it('offers compute, sql and memorystore, not storage, in the override Add list', async () => {
it('offers compute, relational-db and cache, not storage, in the override Add list', async () => {
(api.listAccountServiceOverrides as jest.Mock).mockResolvedValue([]);
panel.innerHTML = '';
await loadOverridesPanel('acct1', panel, 'gcp' as never);
Expand All @@ -59,7 +59,7 @@ describe('GCP storage removal (issue #543)', () => {
add.click();
await new Promise(r => setTimeout(r, 0));
const options = Array.from((document.getElementById('override-service') as HTMLSelectElement).options).map(o => o.value);
expect(options).toEqual(['compute', 'sql', 'memorystore']);
expect(options).toEqual(['compute', 'relational-db', 'cache']);
});

it('still renders a stored gcp/storage override with a Delete button', () => {
Expand Down
181 changes: 181 additions & 0 deletions frontend/src/__tests__/settings-service-ids.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
/**
* Settings service ids for Azure and GCP (issue #613).
*
* Recommendations carry the provider's common.ServiceType slug and the
* scheduler / purchase flow match service configs and per-account overrides
* on that exact id, so Settings must write the same ids. The DOM is the real
* frontend/src/index.html and the backend slug list is read from
* internal/purchase/execution.go (mapServiceSlug), so a drift fails here.
*/

import * as fs from 'fs';
import * as path from 'path';
import { loadGlobalSettings, saveGlobalSettings, loadOverridesPanel } from '../settings';
import { SETTINGS_SERVICE_IDS, LEGACY_SETTINGS_SERVICES, canonicalizeServiceConfigs, legacySettingsServiceTarget } from '../lib/settings-service-ids';

jest.mock('../api', () => ({
getConfig: jest.fn(),
updateConfig: jest.fn(),
updateServiceConfig: jest.fn(),
getLadderConfigs: jest.fn().mockResolvedValue([]),
listAccountServiceOverrides: jest.fn(),
saveAccountServiceOverride: jest.fn(),
deleteAccountServiceOverride: jest.fn(),
}));
jest.mock('../federation', () => ({ initFederationPanel: jest.fn().mockResolvedValue(undefined) }));
jest.mock('../confirmDialog', () => ({ confirmDialog: jest.fn(() => Promise.resolve(true)) }));
jest.mock('../toast', () => ({ showToast: jest.fn(() => ({ dismiss: jest.fn() })) }));

import * as api from '../api';

// Cosmos DB recommendations carry common.ServiceNoSQL ("nosql"), which
// mapServiceSlug does not list yet (no Cosmos purchase path). Settings must
// still use that id so its config and overrides match those recommendations.
const NOT_IN_PURCHASE_SLUGS = ['azure/nosql'];

function backendServiceSlugs(): string[] {
const src = fs.readFileSync(path.join(__dirname, '..', '..', '..', 'internal', 'purchase', 'execution.go'), 'utf-8');
const block = /func mapServiceSlug\(service string\)[\s\S]*?slugs := map\[string\]common\.ServiceType\{([\s\S]*?)\n\t\}/.exec(src);
if (!block) throw new Error('mapServiceSlug slug map not found in execution.go');
const slugs = [...block[1]!.matchAll(/"([\w-]+)":/g)].map(m => m[1]!);
if (!slugs.includes('compute')) throw new Error(`unexpected mapServiceSlug parse: ${JSON.stringify(slugs)}`);
return slugs;
}

function mountRealIndexHtml(): void {
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf-8');
document.body.innerHTML = new DOMParser().parseFromString(html, 'text/html').body.innerHTML;
}

const flush = () => new Promise(r => setTimeout(r, 0));

function mockConfig(services: unknown[]): void {
(api.getConfig as jest.Mock).mockResolvedValue({
global: { enabled_providers: ['aws', 'azure', 'gcp'], default_term: 3, default_payment: 'all-upfront', default_coverage: 80, notification_days_before: 3 },
services,
});
}

function servicePuts(provider?: string): Array<{ provider: string; service: string; cfg: Record<string, unknown> }> {
return (api.updateServiceConfig as jest.Mock).mock.calls
.map(([p, s, cfg]) => ({ provider: p, service: s, cfg }))
.filter(c => provider === undefined || c.provider === provider);
}

async function overrideOptions(provider: 'azure' | 'gcp'): Promise<string[]> {
(api.listAccountServiceOverrides as jest.Mock).mockResolvedValue([]);
const panel = document.createElement('div');
document.body.appendChild(panel);
await loadOverridesPanel('acct1', panel, provider);
await flush();
return Array.from((document.getElementById('override-service') as HTMLSelectElement).options).map(o => o.value);
}

describe('canonicalizeServiceConfigs (issue #613)', () => {
it('maps every legacy id onto a current Settings id of the same provider', () => {
for (const [key, target] of Object.entries(LEGACY_SETTINGS_SERVICES)) {
const provider = key.split('/')[0] as 'azure' | 'gcp';
expect(SETTINGS_SERVICE_IDS[provider]).toContain(target);
}
});

it('re-keys a legacy row, keeps a current row over its legacy twin and passes others through', () => {
const rows = [
{ provider: 'azure', service: 'vm', term: 1 },
{ provider: 'gcp', service: 'memorystore', term: 1 },
{ provider: 'gcp', service: 'cache', term: 3 },
{ provider: 'aws', service: 'ec2', term: 3 },
{ provider: 'gcp', service: 'storage', term: 1 },
];
expect(canonicalizeServiceConfigs(rows)).toEqual([
{ provider: 'azure', service: 'compute', term: 1 },
{ provider: 'gcp', service: 'cache', term: 3 },
{ provider: 'aws', service: 'ec2', term: 3 },
{ provider: 'gcp', service: 'storage', term: 1 },
]);
expect(rows[0]!.service).toBe('vm');
});

it.each([['aws', 'vm'], ['azure', 'compute'], ['gcp', 'cloudsql'], ['azure', 'toString'], ['', '']])(
'does not treat %s/%s as a legacy Settings id', (provider, service) => {
expect(legacySettingsServiceTarget(provider, service)).toBeNull();
});
});

describe('Settings Azure/GCP service ids (issue #613)', () => {
beforeEach(() => {
jest.clearAllMocks();
mountRealIndexHtml();
mockConfig([]);
(api.updateConfig as jest.Mock).mockResolvedValue(undefined);
(api.updateServiceConfig as jest.Mock).mockResolvedValue(undefined);
});

it('every Azure/GCP id the cards save and the override list offers is a backend service slug', async () => {
await loadGlobalSettings();
await saveGlobalSettings(new Event('submit'));
const slugs = backendServiceSlugs();
const offered: string[] = [];
for (const provider of ['azure', 'gcp'] as const) {
const saved = servicePuts(provider).map(c => c.service);
expect(saved).toEqual(SETTINGS_SERVICE_IDS[provider]);
expect(await overrideOptions(provider)).toEqual(SETTINGS_SERVICE_IDS[provider]);
offered.push(...saved.map(s => `${provider}/${s}`));
}
expect(offered.filter(id => !slugs.includes(id.split('/')[1]!))).toEqual(NOT_IN_PURCHASE_SLUGS);
});

it('shows rows stored under the old ids on their cards and saves them under the current ids', async () => {
mockConfig([
{ provider: 'azure', service: 'vm', term: 1, payment: 'monthly', enabled: true, coverage: 80, ramp_schedule: 'r1', include_regions: ['eastus'] },
{ provider: 'azure', service: 'cosmosdb', term: 1, payment: 'monthly', enabled: true, coverage: 80 },
{ provider: 'gcp', service: 'memorystore', term: 1, payment: 'monthly', enabled: true, coverage: 80 },
]);
await loadGlobalSettings();
expect((document.getElementById('azure-vm-term') as HTMLSelectElement).value).toBe('1');
expect((document.getElementById('azure-vm-payment') as HTMLSelectElement).value).toBe('monthly');
expect((document.getElementById('azure-cosmosdb-term') as HTMLSelectElement).value).toBe('1');
expect((document.getElementById('gcp-memorystore-term') as HTMLSelectElement).value).toBe('1');
expect((document.getElementById('azure-sql-term') as HTMLSelectElement).value).toBe('3');

await saveGlobalSettings(new Event('submit'));
const puts = servicePuts();
const legacy = new Set(Object.keys(LEGACY_SETTINGS_SERVICES));
expect(puts.filter(c => legacy.has(`${c.provider}/${c.service}`))).toEqual([]);
const compute = puts.find(c => c.provider === 'azure' && c.service === 'compute')!.cfg;
expect(compute).toEqual(expect.objectContaining({ term: 1, payment: 'monthly', ramp_schedule: 'r1', include_regions: ['eastus'] }));
expect(puts.find(c => c.provider === 'azure' && c.service === 'nosql')!.cfg['term']).toBe(1);
expect(puts.find(c => c.provider === 'gcp' && c.service === 'cache')!.cfg['term']).toBe(1);
});

it('prefers a row already stored under the current id over its legacy twin', async () => {
mockConfig([
{ provider: 'azure', service: 'vm', term: 1, payment: 'monthly', enabled: true, coverage: 80 },
{ provider: 'azure', service: 'compute', term: 3, payment: 'upfront', enabled: true, coverage: 80 },
]);
await loadGlobalSettings();
expect((document.getElementById('azure-vm-term') as HTMLSelectElement).value).toBe('3');
expect((document.getElementById('azure-vm-payment') as HTMLSelectElement).value).toBe('upfront');
});

it('flags an override stored under an old id and still offers the current id', async () => {
(api.listAccountServiceOverrides as jest.Mock).mockResolvedValue([
{ id: 'o1', account_id: 'acct1', provider: 'azure', service: 'vm', term: 1, enabled: true },
{ id: 'o2', account_id: 'acct1', provider: 'azure', service: 'relational-db', term: 1, enabled: true },
]);
const panel = document.createElement('div');
document.body.appendChild(panel);
await loadOverridesPanel('acct1', panel, 'azure');
await flush();
const rows = Array.from(panel.querySelectorAll('tbody tr'));
const vmRow = rows.find(r => r.querySelector('td')!.textContent!.startsWith('azure/vm'))!;
const dbRow = rows.find(r => r.querySelector('td')!.textContent!.startsWith('azure/relational-db'))!;
expect(vmRow.querySelector('.override-legacy-hint')!.textContent).toContain('azure/compute');
expect(dbRow.querySelector('.override-legacy-hint')).toBeNull();

(panel.querySelector('button') as HTMLButtonElement).click();
await flush();
const options = Array.from((document.getElementById('override-service') as HTMLSelectElement).options).map(o => o.value);
expect(options).toEqual(['compute', 'nosql', 'cache', 'search']);
});
});
14 changes: 7 additions & 7 deletions frontend/src/__tests__/settings-service-payment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,16 +98,16 @@ describe('Settings Save payment tokens (issue #545)', () => {
await saveGlobalSettings(new Event('submit'));

const calls = serviceCalls();
expect(calls.find(c => c.provider === 'azure' && c.service === 'vm')!.payment).toBe('monthly');
expect(calls.find(c => c.provider === 'azure' && c.service === 'sql')!.payment).toBe('upfront');
expect(calls.find(c => c.provider === 'azure' && c.service === 'compute')!.payment).toBe('monthly');
expect(calls.find(c => c.provider === 'azure' && c.service === 'relational-db')!.payment).toBe('upfront');
for (const c of calls.filter(x => x.provider === 'gcp')) expect(c.payment).toBe('monthly');
});

it('has no GCP Cloud Storage card and never PUTs gcp/storage (issue #543)', async () => {
expect(document.getElementById('gcp-storage-term')).toBeNull();
await save();
expect(serviceCalls().filter(c => c.provider === 'gcp').map(c => c.service))
.toEqual(['compute', 'sql', 'memorystore']);
.toEqual(['compute', 'relational-db', 'cache']);
});

it('ignores a stored gcp/storage row on load and keeps the other GCP cards', async () => {
Expand Down Expand Up @@ -150,7 +150,7 @@ describe('Settings Save payment tokens (issue #545)', () => {
it('one failed service PUT reports that service, still saves the rest and keeps only its fields dirty', async () => {
const consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined);
(api.updateServiceConfig as jest.Mock).mockImplementation(async (provider: string, service: string) => {
if (provider === 'azure' && service === 'vm') throw new Error('invalid payment option');
if (provider === 'azure' && service === 'compute') throw new Error('invalid payment option');
});
await loadGlobalSettings();
const azurePayment = document.getElementById('azure-vm-payment') as HTMLSelectElement;
Expand All @@ -163,7 +163,7 @@ describe('Settings Save payment tokens (issue #545)', () => {
expect(api.updateServiceConfig).toHaveBeenCalledTimes(17);
expect(mockShowToast).toHaveBeenCalledTimes(1);
expect(mockShowToast).toHaveBeenCalledWith({
message: 'Settings saved, but 1 service failed: azure/vm: invalid payment option',
message: 'Settings saved, but 1 service failed: azure/compute: invalid payment option',
kind: 'error',
});
expect(azurePayment.classList.contains('dirty')).toBe(true);
Expand Down Expand Up @@ -241,7 +241,7 @@ describe('Settings Save payment tokens (issue #545)', () => {
await save();

expect((document.getElementById('azure-vm-payment') as HTMLSelectElement).value).toBe(expected);
expect(serviceCalls().find(c => c.provider === 'azure' && c.service === 'vm')!.payment).toBe(expected);
expect(serviceCalls().find(c => c.provider === 'azure' && c.service === 'compute')!.payment).toBe(expected);
});

it('never falls back to upfront when a payment select has no matching option at Save', async () => {
Expand All @@ -250,7 +250,7 @@ describe('Settings Save payment tokens (issue #545)', () => {
expect((document.getElementById('azure-vm-payment') as HTMLSelectElement).value).toBe('');
await saveGlobalSettings(new Event('submit'));

expect(serviceCalls().find(c => c.provider === 'azure' && c.service === 'vm')!.payment).toBe('monthly');
expect(serviceCalls().find(c => c.provider === 'azure' && c.service === 'compute')!.payment).toBe('monthly');
});

it('counts only AWS services when asking to propagate the default payment', async () => {
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/__tests__/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1434,7 +1434,7 @@ describe('Settings Module', () => {
await saveGlobalSettings({ preventDefault: jest.fn() } as unknown as Event);

const azureVmCall = (api.updateServiceConfig as jest.Mock).mock.calls.find(
([provider, service]) => provider === 'azure' && service === 'vm',
([provider, service]) => provider === 'azure' && service === 'compute',
);
expect(azureVmCall).toBeDefined();
const cfg = azureVmCall![2];
Expand Down
53 changes: 53 additions & 0 deletions frontend/src/lib/settings-service-ids.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// Service ids for the Settings page's Azure/GCP per-service defaults and
// per-account overrides (#613).
//
// Recommendations carry the provider's common.ServiceType slug (compute,
// relational-db, nosql, cache, search), and both the scheduler
// (config.ResolveAccountConfigsForRecs) and the purchase modal look up
// service_configs / account_service_overrides rows by that exact
// (provider, service) pair. Settings used to write Azure/GCP rows under
// per-product ids (vm, sql, cosmosdb, redis, memorystore) that no
// recommendation carries, so those rows never applied.

/** Service ids the Settings page writes for Azure and GCP, per provider. */
export const SETTINGS_SERVICE_IDS: Readonly<Record<'azure' | 'gcp', readonly string[]>> = {
azure: ['compute', 'relational-db', 'nosql', 'cache', 'search'],
gcp: ['compute', 'relational-db', 'cache'],
};

/** Old Settings ids (`provider/service`) mapped to the id recommendations carry. */
export const LEGACY_SETTINGS_SERVICES: Readonly<Record<string, string>> = {
'azure/vm': 'compute',
'azure/sql': 'relational-db',
'azure/cosmosdb': 'nosql',
'azure/redis': 'cache',
'gcp/sql': 'relational-db',
'gcp/memorystore': 'cache',
};

/** The current id for a row stored under an old Settings id, or null when the id is not a legacy one. */
export function legacySettingsServiceTarget(provider: string, service: string): string | null {
const key = `${provider}/${service}`;
return Object.prototype.hasOwnProperty.call(LEGACY_SETTINGS_SERVICES, key) ? LEGACY_SETTINGS_SERVICES[key]! : null;
}

/**
* Re-keys rows stored under an old Settings id onto the current id so the
* matching card shows the stored values and the next Save writes them under
* the id the backend matches. A row already stored under the current id wins
* over its legacy twin, which is dropped. Other rows pass through unchanged.
*/
export function canonicalizeServiceConfigs<T extends { provider: string; service: string }>(rows: readonly T[]): T[] {
const present = new Set(rows.map(r => `${r.provider}/${r.service}`));
const out: T[] = [];
for (const row of rows) {
const target = legacySettingsServiceTarget(row.provider, row.service);
if (target === null) {
out.push(row);
} else if (!present.has(`${row.provider}/${target}`)) {
present.add(`${row.provider}/${target}`);
out.push({ ...row, service: target });
}
}
return out;
}
Loading
Loading