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
95 changes: 94 additions & 1 deletion frontend/src/__tests__/settings-accounts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ jest.mock('../api', () => ({
updateAccount: jest.fn(),
deleteAccount: jest.fn(),
testAccountCredentials: jest.fn(),
saveAccountCredentials: jest.fn()
saveAccountCredentials: jest.fn(),
listAccountServiceOverrides: jest.fn(),
saveAccountServiceOverride: jest.fn(),
deleteAccountServiceOverride: jest.fn()
}));

const mockShowToast = jest.fn<{ dismiss: () => void }, [unknown]>(() => ({ dismiss: jest.fn() }));
Expand Down Expand Up @@ -472,3 +475,93 @@ describe('Account form submit', () => {
});
});
});

// ---------------------------------------------------------------------------
// Account overrides panel — payment option selector (issue #23)
// ---------------------------------------------------------------------------

describe('Overrides panel — AWS payment selector', () => {
/**
* Render the AWS accounts list and expand the first account's overrides
* panel so the panel DOM is populated with whatever
* listAccountServiceOverrides has been mocked to return.
*/
async function openOverridesPanel(accountId = 'acc-1'): Promise<HTMLElement> {
(api.listAccounts as jest.Mock).mockResolvedValue([
{ id: accountId, name: 'Prod', provider: 'aws', external_id: '111', enabled: true },
]);
await loadAccountsForProvider('aws');
const overridesBtn = document.querySelector(
`button[aria-label="Service overrides for Prod (111)"]`,
) as HTMLButtonElement | null;
expect(overridesBtn).not.toBeNull();
overridesBtn!.click();
// loadOverridesPanel is async; let microtasks flush.
await new Promise(r => setTimeout(r, 0));
const panel = document.querySelector('.account-overrides-panel') as HTMLElement | null;
expect(panel).not.toBeNull();
return panel!;
}

beforeEach(() => {
buildAccountsDOM();
jest.clearAllMocks();
});

test('renders payment <select> with Inherit + 3 AWS options for AWS overrides', async () => {
(api.listAccountServiceOverrides as jest.Mock).mockResolvedValue([
{ id: 'o1', account_id: 'acc-1', provider: 'aws', service: 'ec2' },
]);

const panel = await openOverridesPanel('acc-1');
const select = panel.querySelector('select.override-payment-select') as HTMLSelectElement | null;
expect(select).not.toBeNull();
const values = Array.from(select!.options).map(o => o.value);
expect(values).toEqual(['', 'no-upfront', 'partial-upfront', 'all-upfront']);
expect(select!.value).toBe(''); // Inherit by default when no payment set
expect(select!.options[0]!.disabled).toBe(false);
});

test('changing payment from Inherit calls saveAccountServiceOverride with only {payment}', async () => {
(api.listAccountServiceOverrides as jest.Mock).mockResolvedValue([
{ id: 'o1', account_id: 'acc-1', provider: 'aws', service: 'rds', term: 1 },
]);
(api.saveAccountServiceOverride as jest.Mock).mockResolvedValue({
id: 'o1', account_id: 'acc-1', provider: 'aws', service: 'rds', term: 1, payment: 'all-upfront',
});

const panel = await openOverridesPanel('acc-1');
const select = panel.querySelector('select.override-payment-select') as HTMLSelectElement;
select.value = 'all-upfront';
select.dispatchEvent(new Event('change'));
await new Promise(r => setTimeout(r, 0));

expect(api.saveAccountServiceOverride).toHaveBeenCalledTimes(1);
expect(api.saveAccountServiceOverride).toHaveBeenCalledWith(
'acc-1', 'aws', 'rds', { payment: 'all-upfront' },
);
});

test('Inherit is disabled when override already has a payment set (no clear-field channel)', async () => {
(api.listAccountServiceOverrides as jest.Mock).mockResolvedValue([
{ id: 'o1', account_id: 'acc-1', provider: 'aws', service: 'ec2', payment: 'partial-upfront' },
]);

const panel = await openOverridesPanel('acc-1');
const select = panel.querySelector('select.override-payment-select') as HTMLSelectElement;
expect(select.value).toBe('partial-upfront');
expect(select.options[0]!.disabled).toBe(true);
// The selector still does NOT call save on initial render (no change yet).
expect(api.saveAccountServiceOverride).not.toHaveBeenCalled();
});

test('non-AWS rows render the existing read-only payment cell, no <select>', async () => {
(api.listAccountServiceOverrides as jest.Mock).mockResolvedValue([
{ id: 'o1', account_id: 'acc-1', provider: 'azure', service: 'vm', payment: 'all-upfront' },
]);

const panel = await openOverridesPanel('acc-1');
expect(panel.querySelector('select.override-payment-select')).toBeNull();
expect(panel.textContent).toContain('all-upfront');
});
});
118 changes: 116 additions & 2 deletions frontend/src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,108 @@ function renderAccountsList(
panels.forEach((p) => container.appendChild(p));
}

// AWS payment-option choices, kept in sync with the per-service Purchasing
// selectors in frontend/src/index.html (e.g. #aws-ec2-payment). Centralised
// here so the override editor and the global selectors can't drift.
const AWS_PAYMENT_OPTIONS: ReadonlyArray<{ value: string; label: string }> = [
{ value: 'no-upfront', label: 'No Upfront' },
{ value: 'partial-upfront', label: 'Partial Upfront' },
{ value: 'all-upfront', label: 'All Upfront' },
];

/**
* Build the per-row Payment <select> for the overrides panel. Issue #23.
*
* - "Inherit (default)" keeps the override silent on payment so the global
* default applies. We only emit it as the chosen value when the override
* currently has no payment set; once a payment is set, switching back
* would require deleting/recreating the override (out of scope for #23).
* - The change handler PUTs only `{ payment }` — the backend's
* applyOverrideScalars (handler_accounts.go) treats unset request fields
* as "leave alone", so other override fields (term, coverage, etc.) are
* preserved.
*/
function buildPaymentOverrideSelect(
accountId: string,
override: api.AccountServiceOverride,
panel: HTMLElement,
): HTMLSelectElement {
const select = document.createElement('select');
select.className = 'override-payment-select';
select.setAttribute(
'aria-label',
`Payment option for ${override.provider}/${override.service} override`,
);

const inheritOpt = document.createElement('option');
inheritOpt.value = '';
inheritOpt.textContent = 'Inherit (default)';
select.appendChild(inheritOpt);

for (const { value, label } of AWS_PAYMENT_OPTIONS) {
const opt = document.createElement('option');
opt.value = value;
opt.textContent = label;
select.appendChild(opt);
}

const initial = override.payment ?? '';
select.value = initial;
select.dataset['previous'] = initial;

// If a payment is already set, "Inherit" is not a clean transition (the
// backend has no clear-field channel — it would require resetting the
// whole override). Disable the option so the user can still pick another
// payment value but can't accidentally pick a no-op state.
if (initial !== '') {
inheritOpt.disabled = true;
inheritOpt.title = 'Use Reset to clear all override fields including payment';
}

select.addEventListener('change', () => {
void handlePaymentOverrideChange(accountId, override, select, panel);
});

return select;
}

async function handlePaymentOverrideChange(
accountId: string,
override: api.AccountServiceOverride,
select: HTMLSelectElement,
panel: HTMLElement,
): Promise<void> {
const previous = select.dataset['previous'] ?? '';
const next = select.value;
if (next === previous) return;
if (next === '') {
// Reverting to Inherit while a payment is set is blocked above; defensive
// no-op here keeps types narrow if the option becomes selectable later.
select.value = previous;
return;
}
select.disabled = true;
try {
await api.saveAccountServiceOverride(accountId, override.provider, override.service, {
payment: next,
});
select.dataset['previous'] = next;
showToast({
message: `Payment override updated for ${override.provider}/${override.service}.`,
kind: 'success',
});
await loadOverridesPanel(accountId, panel);
} catch (err) {
select.value = previous;
showToast({
message: `Failed to update payment override: ${(err as Error).message}`,
kind: 'error',
});
} finally {
select.disabled = false;
}
}

/**
* Load and render the service overrides panel for an account
*/
Expand Down Expand Up @@ -451,12 +553,24 @@ async function loadOverridesPanel(accountId: string, panel: HTMLElement): Promis
[
`${o.provider}/${o.service}`,
o.term !== undefined ? `${o.term}yr` : '\u2014',
o.payment ?? '\u2014',
o.coverage !== undefined ? `${o.coverage}%` : '\u2014',
].forEach(text => {
const td = tr.insertCell();
td.textContent = text;
});

// Payment cell: editable <select> for AWS (the only provider whose
// reservations support distinct payment options); read-only text for
// Azure/GCP. Issue #23.
const paymentTd = tr.insertCell();
if (o.provider === 'aws') {
paymentTd.appendChild(buildPaymentOverrideSelect(accountId, o, panel));
} else {
paymentTd.textContent = o.payment ?? '\u2014';
}

const coverageTd = tr.insertCell();
coverageTd.textContent = o.coverage !== undefined ? `${o.coverage}%` : '\u2014';

const actionTd = tr.insertCell();
const resetBtn = document.createElement('button');
resetBtn.type = 'button';
Expand Down