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
152 changes: 152 additions & 0 deletions frontend/src/__tests__/recommendations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3125,6 +3125,158 @@ describe('Issue #111: per-bucket Payment seed from per-account service override'
// openPurchaseModal instead of openFanOutModal).
expect(buckets).toBeNull();
});

// Issue #197: multi-account bucket exposes per-rec payment defaults seeded
// from each rec's account override. The bucket-level payment falls back to
// the toolbar; perRecPayments overrides per rec.
test('(h) issue #197: multi-account bucket carries per-rec payment map seeded from each account override', async () => {
// Two recs, same (provider, service, term, payment) bucket key so they
// land in ONE bucket, but different cloud_account_ids — triggers the
// multi-account per-rec seeding path. Need a second bucket (different term)
// to force fan-out via openFanOutModal.
const recs = [
{ id: 'h1', provider: 'aws', cloud_account_id: 'acct-x', service: 'ec2', resource_type: 't3.medium', region: 'us-east-1', count: 1, term: 1, payment: 'all-upfront', savings: 100, upfront_cost: 500 },
{ id: 'h2', provider: 'aws', cloud_account_id: 'acct-y', service: 'ec2', resource_type: 't3.medium', region: 'us-east-1', count: 1, term: 1, payment: 'all-upfront', savings: 150, upfront_cost: 600 },
// Second bucket (3yr, single account) just to force fan-out modal.
{ id: 'h3', provider: 'aws', cloud_account_id: 'acct-x', service: 'ec2', resource_type: 'm5.large', region: 'us-east-1', count: 1, term: 3, payment: 'all-upfront', savings: 300, upfront_cost: 1200 },
];
setupMixedTermRecs(recs);
// acct-x prefers partial-upfront; acct-y prefers no-upfront.
(api.listAccountServiceOverrides as jest.Mock).mockImplementation(async (id: string) => {
if (id === 'acct-x') return [{ id: 'ovr-x', account_id: 'acct-x', provider: 'aws', service: 'ec2', payment: 'partial-upfront' }];
if (id === 'acct-y') return [{ id: 'ovr-y', account_id: 'acct-y', provider: 'aws', service: 'ec2', payment: 'no-upfront' }];
return [];
});

await loadRecommendations();
(document.getElementById('bulk-purchase-btn') as HTMLButtonElement).click();
await Promise.resolve(); await Promise.resolve(); await Promise.resolve();

const { getFanOutBuckets } = await import('../recommendations');
const buckets = getFanOutBuckets();
expect(buckets).not.toBeNull();
const bucket1yr = buckets!.find((b) => b.term === 1)!;
expect(bucket1yr).toBeDefined();

// The 1yr bucket is multi-account: perRecPayments must be present.
expect(bucket1yr.perRecPayments).toBeDefined();
const prp = bucket1yr.perRecPayments!;
// h1 (acct-x) seeded from partial-upfront override.
expect(prp.get('h1')).toBe('partial-upfront');
// h2 (acct-y) seeded from no-upfront override.
expect(prp.get('h2')).toBe('no-upfront');

// Bucket-level payment falls back to toolbar (multi-account, no single override).
expect(bucket1yr.paymentSource).toBe('toolbar');

// Per-rec dropdowns must be rendered in the modal.
const perRecSelects = document.querySelectorAll<HTMLSelectElement>('.fanout-per-rec-payment');
expect(perRecSelects.length).toBeGreaterThanOrEqual(2);
const h1Select = Array.from(perRecSelects).find((s) => s.dataset['recId'] === 'h1');
const h2Select = Array.from(perRecSelects).find((s) => s.dataset['recId'] === 'h2');
expect(h1Select?.value).toBe('partial-upfront');
expect(h2Select?.value).toBe('no-upfront');
});

test('(i) issue #197: per-rec dropdown change updates perRecPayments in module state', async () => {
// Same multi-account 1yr bucket as (h), plus a 3yr bucket to force fan-out.
const recs = [
{ id: 'i1', provider: 'aws', cloud_account_id: 'acct-p', service: 'ec2', resource_type: 't3.medium', region: 'us-east-1', count: 1, term: 1, payment: 'all-upfront', savings: 100, upfront_cost: 500 },
{ id: 'i2', provider: 'aws', cloud_account_id: 'acct-q', service: 'ec2', resource_type: 't3.medium', region: 'us-east-1', count: 1, term: 1, payment: 'all-upfront', savings: 120, upfront_cost: 550 },
{ id: 'i3', provider: 'aws', cloud_account_id: 'acct-p', service: 'ec2', resource_type: 'm5.large', region: 'us-east-1', count: 1, term: 3, payment: 'all-upfront', savings: 300, upfront_cost: 1200 },
];
setupMixedTermRecs(recs);
(api.listAccountServiceOverrides as jest.Mock).mockResolvedValue([]);

await loadRecommendations();
(document.getElementById('bulk-purchase-btn') as HTMLButtonElement).click();
await Promise.resolve(); await Promise.resolve(); await Promise.resolve();

const { getFanOutBuckets } = await import('../recommendations');
const before = getFanOutBuckets();
const bucket1yr = before!.find((b) => b.term === 1)!;
expect(bucket1yr.perRecPayments).toBeDefined();
// No overrides: both recs match the bucket default (all-upfront), so the
// map holds ONLY explicit overrides — i1/i2 are absent and fall back to
// b.payment via the execute path. (Eager population would make the
// bucket-level dropdown a no-op for these rows.)
expect(bucket1yr.perRecPayments!.has('i1')).toBe(false);
expect(bucket1yr.perRecPayments!.has('i2')).toBe(false);

// User changes the i1 dropdown to no-upfront — now an explicit override.
const i1Select = Array.from(
document.querySelectorAll<HTMLSelectElement>('.fanout-per-rec-payment'),
).find((s) => s.dataset['recId'] === 'i1')!;
expect(i1Select).toBeDefined();
i1Select.value = 'no-upfront';
i1Select.dispatchEvent(new Event('change'));

const after = getFanOutBuckets();
const afterBucket1yr = after!.find((b) => b.term === 1)!;
expect(afterBucket1yr.perRecPayments!.get('i1')).toBe('no-upfront');
// i2 still follows the bucket default — absent from the override map.
expect(afterBucket1yr.perRecPayments!.has('i2')).toBe(false);

// Setting i1 back to the bucket default removes the override again so the
// row resumes tracking the bucket-level dropdown.
i1Select.value = 'all-upfront';
i1Select.dispatchEvent(new Event('change'));
const reset = getFanOutBuckets();
expect(reset!.find((b) => b.term === 1)!.perRecPayments!.has('i1')).toBe(false);
});

// Issue #197 regression (CR #838): the bucket-level Payment dropdown must
// remain effective for multi-account rows that follow the bucket default.
// Before the fix, openFanOutModal eagerly wrote every rec into
// perRecPayments, so changing the bucket dropdown only mutated b.payment
// while the execute path still read the stale per-rec entry — making the
// visible control a no-op for unedited rows.
test('(j) issue #197: bucket-level Payment change propagates to non-overridden recs in the POST payload', async () => {
const recs = [
{ id: 'j1', provider: 'aws', cloud_account_id: 'acct-r', service: 'ec2', resource_type: 't3.medium', region: 'us-east-1', count: 1, term: 1, payment: 'all-upfront', savings: 100, upfront_cost: 500 },
{ id: 'j2', provider: 'aws', cloud_account_id: 'acct-s', service: 'ec2', resource_type: 't3.medium', region: 'us-east-1', count: 1, term: 1, payment: 'all-upfront', savings: 120, upfront_cost: 550 },
{ id: 'j3', provider: 'aws', cloud_account_id: 'acct-r', service: 'ec2', resource_type: 'm5.large', region: 'us-east-1', count: 1, term: 3, payment: 'all-upfront', savings: 300, upfront_cost: 1200 },
];
setupMixedTermRecs(recs);
(api.listAccountServiceOverrides as jest.Mock).mockResolvedValue([]);

await loadRecommendations();
(document.getElementById('bulk-purchase-btn') as HTMLButtonElement).click();
await Promise.resolve(); await Promise.resolve(); await Promise.resolve();

// Change the multi-account 1yr bucket's bucket-level Payment dropdown.
const bucketSelects = Array.from(
document.querySelectorAll<HTMLSelectElement>('.fanout-bucket-payment'),
);
// The 1yr bucket is the multi-account one (renders per-rec selects); find
// the bucket section that contains per-rec rows.
const targetSelect = bucketSelects.find((sel) => {
const section = sel.closest('.fanout-bucket');
return section?.querySelector('.fanout-per-rec-payment') != null;
})!;
expect(targetSelect).toBeDefined();
targetSelect.value = 'no-upfront';
targetSelect.dispatchEvent(new Event('change'));

const { getFanOutBuckets } = await import('../recommendations');
const buckets = getFanOutBuckets()!;
const bucket1yr = buckets.find((b) => b.term === 1)!;
// Execute path: payment = perRecPayments.get(id) ?? b.payment. With the
// override map empty for unedited rows, both recs must post the NEW
// bucket payment.
const resolved = (id: string): string => bucket1yr.perRecPayments?.get(id) ?? bucket1yr.payment;
expect(resolved('j1')).toBe('no-upfront');
expect(resolved('j2')).toBe('no-upfront');

// The visible per-rec selects must reflect the new bucket default too.
const perRecSelects = Array.from(
document.querySelectorAll<HTMLSelectElement>('.fanout-per-rec-payment'),
);
const j1Select = perRecSelects.find((s) => s.dataset['recId'] === 'j1');
const j2Select = perRecSelects.find((s) => s.dataset['recId'] === 'j2');
expect(j1Select?.value).toBe('no-upfront');
expect(j2Select?.value).toBe('no-upfront');
});
});

// Issue #111 (iii): per-row Payment seed in openPurchaseModal — the
Expand Down
14 changes: 8 additions & 6 deletions frontend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -470,17 +470,19 @@ async function handleFanOutExecute(buckets: FanOutBucket[]): Promise<void> {
// server-provided rec so `details`, `engine`, `cloud_account_id`, and
// any future additions flow through unchanged. Only `payment`,
// `monthly_cost`, `selected`, and `purchased` are overridden: `payment`
// comes from the bucket (user's per-bucket choice), `monthly_cost` is
// coerced to null for absent values, and the purchase-intent flags are
// forced to their canonical values. Passing `details` ensures
// non-default platforms (Windows EC2, dedicated tenancy, AZ-scoped RIs,
// non-default-engine RDS/Cache) reach the backend correctly (issue #597).
// comes from perRecPayments[rec.id] for multi-account buckets (issue #197)
// or from the bucket-level payment for single-account buckets.
// `monthly_cost` is coerced to null for absent values, and the
// purchase-intent flags are forced to their canonical values. Passing
// `details` ensures non-default platforms (Windows EC2, dedicated tenancy,
// AZ-scoped RIs, non-default-engine RDS/Cache) reach the backend correctly
// (issue #597).
const promises = buckets.map((b) =>
api.executePurchase(
b.recs.map((r) => ({
...r,
monthly_cost: r.monthly_cost ?? null,
payment: b.payment,
payment: b.perRecPayments?.get(r.id) ?? b.payment,
selected: true,
purchased: false,
})),
Expand Down
Loading
Loading