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
47 changes: 47 additions & 0 deletions frontend/src/__tests__/plans.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
16 changes: 11 additions & 5 deletions frontend/src/plans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,8 @@ function renderPlannedPurchases(purchases: PlannedPurchase[]): void {
container.querySelectorAll<HTMLButtonElement>('[data-action]').forEach(btn => {
btn.addEventListener('click', () => void handlePlannedPurchaseAction(
btn.dataset['action'] || '',
btn.dataset['id'] || ''
btn.dataset['id'] || '',
btn.dataset['planId'] || ''
));
});
}
Expand Down Expand Up @@ -197,7 +198,7 @@ function renderPlannedPurchaseRow(purchase: PlannedPurchase): string {
${canManagePlan && canRun ? `<button data-action="run" data-id="${purchase.id}" class="btn-small primary" title="Run now">▶</button>` : ''}
${canManagePlan && isPending ? `<button data-action="pause" data-id="${purchase.id}" class="btn-small" title="Pause">⏸</button>` : ''}
${canManagePlan && isPaused ? `<button data-action="resume" data-id="${purchase.id}" class="btn-small" title="Resume">⏵</button>` : ''}
${canManagePlan ? `<button data-action="edit" data-id="${purchase.id}" class="btn-small" title="Edit Plan">✎</button>` : ''}
${canManagePlan ? `<button data-action="edit" data-id="${purchase.id}" data-plan-id="${purchase.plan_id}" class="btn-small" title="Edit Plan">✎</button>` : ''}
${canDisablePlan ? `<button data-action="disable" data-id="${purchase.id}" class="btn-small danger" title="Disable Plan">✕</button>` : ''}
</td>
</tr>
Expand All @@ -221,7 +222,7 @@ function getPlannedPurchaseStatusClass(status: string): string {
/**
* Handle planned purchase action
*/
async function handlePlannedPurchaseAction(action: string, purchaseId: string): Promise<void> {
async function handlePlannedPurchaseAction(action: string, purchaseId: string, planId = ''): Promise<void> {
try {
switch (action) {
case 'run':
Expand All @@ -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.')) {
Expand Down
Loading