diff --git a/frontend/src/__tests__/plans.test.ts b/frontend/src/__tests__/plans.test.ts index 333805fbe..ec2cfe64a 100644 --- a/frontend/src/__tests__/plans.test.ts +++ b/frontend/src/__tests__/plans.test.ts @@ -838,6 +838,53 @@ describe('Plans Module', () => { expect(mockShowToast).toHaveBeenCalledWith(expect.objectContaining({ message: 'Failed to pause purchase: API Error' })); }); + + test('edit action calls getPlan with plan_id, not the purchase id (#773)', async () => { + // The purchase row has id="purchase-1" and plan_id="plan-1". + // Before the fix, editPlan received "purchase-1", causing GET /plans/purchase-1 + // to return 404 and surfacing "Failed to load plan details". + (api.getPlan as jest.Mock).mockResolvedValue({ + id: 'plan-1', + name: 'Test Plan', + enabled: true, + auto_purchase: false, + notification_days_before: 3, + services: { + ec2: { provider: 'aws', service: 'ec2', enabled: true, term: 1, payment: 'all-upfront', coverage: 80 }, + }, + ramp_schedule: { type: 'immediate', percent_per_step: 100, step_interval_days: 0 }, + }); + + const editBtn = document.querySelector('[data-action="edit"]') as HTMLButtonElement; + editBtn?.click(); + + await new Promise(resolve => setTimeout(resolve, 50)); + + // Must use the plan FK, not the purchase PK. + expect(api.getPlan).toHaveBeenCalledWith('plan-1'); + expect(api.getPlan).not.toHaveBeenCalledWith('purchase-1'); + // No error toast should fire. + expect(mockShowToast).not.toHaveBeenCalledWith( + expect.objectContaining({ message: 'Failed to load plan details' }), + ); + }); + + test('edit action with empty plan id is a no-op (defensive guard)', async () => { + // Simulate a button whose data-plan-id attribute is missing/empty by + // directly injecting a button without the attribute and clicking it. + const container = document.getElementById('planned-purchases-list'); + const btn = document.createElement('button'); + btn.dataset.action = 'edit'; + btn.dataset.id = 'purchase-1'; + // intentionally omit data-plan-id so planId defaults to '' + container?.appendChild(btn); + btn.click(); + + await new Promise(resolve => setTimeout(resolve, 50)); + + // getPlan must NOT be called when planId is empty. + expect(api.getPlan).not.toHaveBeenCalled(); + }); }); describe('resume action for paused purchase', () => { diff --git a/frontend/src/plans.ts b/frontend/src/plans.ts index 39a9cb395..b3572038c 100644 --- a/frontend/src/plans.ts +++ b/frontend/src/plans.ts @@ -148,7 +148,8 @@ function renderPlannedPurchases(purchases: PlannedPurchase[]): void { container.querySelectorAll('[data-action]').forEach(btn => { btn.addEventListener('click', () => void handlePlannedPurchaseAction( btn.dataset['action'] || '', - btn.dataset['id'] || '' + btn.dataset['id'] || '', + btn.dataset['planId'] || '' )); }); } @@ -197,7 +198,7 @@ function renderPlannedPurchaseRow(purchase: PlannedPurchase): string { ${canManagePlan && canRun ? `` : ''} ${canManagePlan && isPending ? `` : ''} ${canManagePlan && isPaused ? `` : ''} - ${canManagePlan ? `` : ''} + ${canManagePlan ? `` : ''} ${canDisablePlan ? `` : ''} @@ -221,7 +222,7 @@ function getPlannedPurchaseStatusClass(status: string): string { /** * Handle planned purchase action */ -async function handlePlannedPurchaseAction(action: string, purchaseId: string): Promise { +async function handlePlannedPurchaseAction(action: string, purchaseId: string, planId = ''): Promise { try { switch (action) { case 'run': @@ -237,8 +238,13 @@ async function handlePlannedPurchaseAction(action: string, purchaseId: string): await api.resumePlannedPurchase(purchaseId); break; case 'edit': - // Open edit modal for the plan - await editPlan(purchaseId); + // Open edit modal for the parent plan using plan_id, not the purchase id. + // The purchase row's data-plan-id attribute carries the plan FK (#773). + if (!planId) { + console.warn('edit action ignored: missing plan id'); + return; + } + await editPlan(planId); return; case 'disable': if (confirm('Disable this plan? The plan will be paused and no purchases will be scheduled. You can re-enable it later from the Plans list.')) {