From e1f592440599be9939247364619c5b0cb184cceb Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 12 Oct 2026 18:47:49 +0200 Subject: [PATCH] fix(frontend): keep zonal EC2 AZ in purchase variant identity and cell key Co-Authored-By: Claude Sonnet 5.5 --- .../src/__tests__/purchase-modal-submit.test.ts | 14 ++++++++++++++ frontend/src/__tests__/recommendations.test.ts | 11 +++++++++++ frontend/src/api/types.ts | 2 ++ frontend/src/recommendations.ts | 7 +++++-- frontend/src/types.ts | 2 ++ 5 files changed, 34 insertions(+), 2 deletions(-) diff --git a/frontend/src/__tests__/purchase-modal-submit.test.ts b/frontend/src/__tests__/purchase-modal-submit.test.ts index 21b50e88..57725b11 100644 --- a/frontend/src/__tests__/purchase-modal-submit.test.ts +++ b/frontend/src/__tests__/purchase-modal-submit.test.ts @@ -492,6 +492,8 @@ describe('Issue #1903: purchase modal re-prices on Term/Payment change', () => { test.each<[string, string, string, Record, unknown]>([ ['ec2', 'platform', 'm5.large', { instance_type: 'm5.large', platform: 'Linux/UNIX', tenancy: 'default', scope: 'Region' }, 'Windows'], ['ec2', 'tenancy', 'm5.large', { instance_type: 'm5.large', platform: 'Linux/UNIX', tenancy: 'default', scope: 'Region' }, 'dedicated'], + ['ec2', 'availability_zone', 'm5.large', { instance_type: 'm5.large', platform: 'Linux/UNIX', tenancy: 'default', scope: 'Availability Zone', availability_zone: 'us-east-1a' }, 'us-east-1b'], + ['compute', 'availability_zone', 'm5.large', { instance_type: 'm5.large', platform: 'Linux/UNIX', tenancy: 'default', scope: 'Availability Zone', availability_zone: 'us-east-1a' }, 'us-east-1b'], ['ec2', 'scope', 'm5.large', { instance_type: 'm5.large', platform: 'Linux/UNIX', tenancy: 'default', scope: 'Region' }, 'Availability Zone'], ['compute', 'platform', 'm5.large', { instance_type: 'm5.large', platform: 'Linux/UNIX', tenancy: 'default', scope: 'Region' }, 'Windows'], ['rds', 'az_config', 'db.r5.large', { engine: 'postgres', az_config: 'single-az' }, 'multi-az'], @@ -547,6 +549,18 @@ describe('Issue #1903: purchase modal re-prices on Term/Payment change', () => { ]); }); + test('purchase identity keeps a regional EC2 rec (no AZ) matching its own variants', async () => { + const details = { instance_type: 'm5.large', platform: 'Linux/UNIX', tenancy: 'default', scope: 'Region' }; + const original = { ...buildRows()[0]!, details }; + const sibling = { ...original, id: 'regional-1-all', term: 1, upfront_cost: 12000 }; + (state.getRecommendations as jest.Mock).mockReturnValue([sibling, original]); + + await openPurchaseModal([original]); + + expect(Array.from(document.querySelector('.purchase-row-term')!.options, (option) => option.value).sort()) + .toEqual(['1', '3']); + }); + test.each([undefined, null, [], 'invalid', { platform: 1 }, {}])('purchase identity does not match populated EC2 details to %j', async (otherDetails) => { const original = { ...buildRows()[0]!, details: { platform: 'Linux/UNIX', tenancy: 'default', scope: 'Region' } }; const other = { ...original, id: 'missing-identity', term: 1, details: otherDetails }; diff --git a/frontend/src/__tests__/recommendations.test.ts b/frontend/src/__tests__/recommendations.test.ts index 56b21658..9f6e90c9 100644 --- a/frontend/src/__tests__/recommendations.test.ts +++ b/frontend/src/__tests__/recommendations.test.ts @@ -5155,6 +5155,17 @@ describe('Issues #225 + #226: cell grouping with savings range and collapse/expa const groups = groupRecsByCell([rec1, rec2]); expect(groups.size).toBe(2); }); + + test('zonal EC2 recs differing only by AZ are separate cells; regional recs are unchanged (issue #792)', () => { + const a = mkRec({ availability_zone: 'us-east-1a' }); + const b = mkRec({ availability_zone: 'us-east-1b' }); + const a2 = mkRec({ availability_zone: 'us-east-1a', term: 3 }); + const regional1 = mkRec({}); + const regional2 = mkRec({ term: 3 }); + const groups = groupRecsByCell([a, b, a2, regional1, regional2]); + expect(groups.size).toBe(3); + expect(Array.from(groups.keys())).toContain('aws||ec2|us-east-1|m5.large|'); + }); }); describe('cellSummary (pure helper)', () => { diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index bc0bc705..c111b915 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -128,6 +128,8 @@ export interface Recommendation { region: string; resource_type: string; engine?: string; + // Zonal EC2 reservation AZ name; absent for regional recs (issue #792). + availability_zone?: string; // Opaque ServiceDetails payload from the backend (json.RawMessage). // The frontend treats this as a pass-through blob: it is stashed when // the GET /api/recommendations response is parsed and forwarded unchanged diff --git a/frontend/src/recommendations.ts b/frontend/src/recommendations.ts index fbdcc94a..d0e81d38 100644 --- a/frontend/src/recommendations.ts +++ b/frontend/src/recommendations.ts @@ -988,7 +988,10 @@ const SORTABLE_STRING_COLUMNS: Record string // Used by issue #224's radio enforcement: at most one variant per cell // can be selected at any time. function cellKey(rec: LocalRecommendation): string { - return `${rec.provider}|${rec.cloud_account_id ?? ''}|${rec.service}|${rec.region}|${rec.resource_type}|${rec.engine ?? ''}`; + const key = `${rec.provider}|${rec.cloud_account_id ?? ''}|${rec.service}|${rec.region}|${rec.resource_type}|${rec.engine ?? ''}`; + // Zonal EC2 recs in different AZs are distinct purchasable cells (issue #792); + // the suffix is added only for them so every other cell key is unchanged. + return rec.availability_zone ? `${key}|${rec.availability_zone}` : key; } // issues #225 + #226: cell-grouping helpers. @@ -1020,7 +1023,7 @@ function samePurchaseVariantIdentity(a: LocalRecommendation, b: LocalRecommendat switch (a.service) { case 'ec2': case 'compute': - fields = ['platform', 'tenancy', 'scope']; + fields = ['platform', 'tenancy', 'scope', 'availability_zone']; break; case 'rds': case 'relational-db': diff --git a/frontend/src/types.ts b/frontend/src/types.ts index fd441a5e..181adea6 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -70,6 +70,8 @@ export interface LocalRecommendation { service: string; resource_type: string; engine?: string; + // Zonal EC2 reservation AZ name; absent for regional recs (issue #792). + availability_zone?: string; // Opaque ServiceDetails payload from the backend (json.RawMessage). // Stashed when the GET /api/recommendations response is parsed and // forwarded unchanged in the POST /api/purchases/execute body so the