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
28 changes: 28 additions & 0 deletions frontend/src/__tests__/plans.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1024,6 +1024,34 @@ describe('Plans Module', () => {
// getPlan must NOT be called when planId is empty.
expect(api.getPlan).not.toHaveBeenCalled();
});

test('edit action reconciles the list when the plan is gone (#1403)', async () => {
// Regression for #1403: a scheduled-purchase row can outlive its plan
// (deleted, or the caller's account scope changed). Clicking Edit passes
// the correct plan_id (the #773/#780 fix is intact), but GET /plans/{id}
// now returns 404. The UI must surface an actionable message AND refresh
// the planned-purchases list so the orphaned row is dropped, instead of
// dead-ending on the generic "Failed to load plan details" toast.
const notFound = Object.assign(new Error('HTTP 404'), { status: 404 });
(api.getPlan as jest.Mock).mockRejectedValue(notFound);
// Clear the getPlannedPurchases call made during beforeEach's loadPlans
// so the assertion below counts only the reconcile triggered by the edit.
(api.getPlannedPurchases as jest.Mock).mockClear();

const editBtn = document.querySelector('[data-action="edit"]') as HTMLButtonElement;
editBtn?.click();

await new Promise(resolve => setTimeout(resolve, 50));

// The right plan FK was used (guards against a #773-class regression).
expect(api.getPlan).toHaveBeenCalledWith('plan-1');
// Actionable, plan-gone message — NOT the generic dead-end.
expect(mockShowToast).toHaveBeenCalledWith(
expect.objectContaining({ message: 'This plan is no longer available. It may have been deleted.', kind: 'error' }),
);
// The stale row is reconciled by refetching the planned purchases.
expect(api.getPlannedPurchases).toHaveBeenCalledTimes(1);
});
});

describe('resume action for paused purchase', () => {
Expand Down
27 changes: 24 additions & 3 deletions frontend/src/plans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -790,7 +790,12 @@ async function handlePlannedPurchaseAction(action: string, purchaseId: string, p
console.warn('edit action ignored: missing plan id');
return;
}
await editPlan(planId);
// If the plan can't be loaded (deleted or no longer accessible while
// the row was on screen), reconcile the list so the orphaned row is
// dropped instead of leaving a dead Edit button (issue #1403).
if (!(await editPlan(planId))) {
await loadPlannedPurchases();
}
return;
case 'disable': {
// Use styled async dialog (11-L2) instead of blocking browser confirm().
Expand Down Expand Up @@ -1100,7 +1105,11 @@ async function togglePlan(planId: string, enabled: boolean): Promise<void> {
}
}

async function editPlan(planId: string): Promise<void> {
// editPlan loads the plan and opens the edit modal pre-filled. Returns true
// when the modal opened, false when the plan could not be loaded (e.g. it was
// deleted or is no longer accessible). Callers that render the plan in a list
// use the false result to reconcile a now-stale row (issue #1403).
async function editPlan(planId: string): Promise<boolean> {
try {
const backendPlan = await api.getPlan(planId) as unknown as BackendPlan;

Expand Down Expand Up @@ -1179,9 +1188,21 @@ async function editPlan(planId: string): Promise<void> {
wirePlanRangeInputs();
const planModal = document.getElementById('plan-modal');
if (planModal) openModal(planModal);
return true;
} catch (error) {
console.error('Failed to load plan:', error);
showToast({ message: 'Failed to load plan details', kind: 'error' });
// A missing/inaccessible plan comes back as 404 (issue #1403): the
// scheduled-purchase row outlived its plan (deleted, or the caller's
// account scope changed). Surface an actionable message instead of the
// generic "Failed to load plan details", and signal failure so the
// caller can reconcile the stale row. Other errors keep the generic
// message (network blip, transient 5xx) since the plan may still exist.
const status = (error as { status?: number }).status;
const message = status === 404
? 'This plan is no longer available. It may have been deleted.'
: 'Failed to load plan details';
showToast({ message, kind: 'error' });
return false;
}
}

Expand Down
13 changes: 12 additions & 1 deletion internal/api/handler_plans.go
Original file line number Diff line number Diff line change
Expand Up @@ -171,9 +171,20 @@ func (h *Handler) getPlan(ctx context.Context, req *events.LambdaFunctionURLRequ
return nil, err
}

// Map a missing plan to 404 (mirrors updatePlan / patchPlan /
// getPlanForPurchaseCreation). GetPurchasePlan wraps config.ErrNotFound,
// which the router's IsNotFoundError check does NOT recognize (it matches
// only the api-package sentinel), so a raw return here surfaced a deleted
// plan as a 500. A scheduled-purchase row can outlive its plan (issue
// #1403: ON DELETE SET NULL detaches the execution, or the user's account
// scope changes), and the Edit button then loads GET /plans/{id}; the
// frontend needs a 404 to distinguish "plan is gone" from a server error
// and reconcile the stale row instead of dead-ending on a generic message.
plan, err := h.config.GetPurchasePlan(ctx, planID)
if err != nil {
return nil, err
return nil, mapCreatePlanStorageError(err,
"plan not found", "failed to load plan",
"getPlan: GetPurchasePlan failed")
}

// Ensure plan has NextExecutionDate calculated
Expand Down
36 changes: 36 additions & 0 deletions internal/api/handler_plans_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package api
import (
"context"
"errors"
"fmt"
"testing"
"time"

Expand Down Expand Up @@ -310,6 +311,41 @@ func TestHandler_getPlan(t *testing.T) {
assert.Equal(t, "12345678-1234-1234-1234-123456789abc", resultPlan.ID)
}

// TestHandler_getPlan_NotFound_MapsTo404 is the backend half of the #1403 fix.
// A scheduled-purchase row can outlive its plan (ON DELETE SET NULL detaches
// the execution, or the caller's account scope changes), and the Edit button
// then loads GET /plans/{id}. GetPurchasePlan wraps config.ErrNotFound, which
// the router's IsNotFoundError sentinel does NOT match, so the pre-fix getPlan
// returned the raw error and the router mapped it to 500. It must be a 404
// (like updatePlan / patchPlan / getPlanForPurchaseCreation) so the frontend
// can tell "plan is gone" from a server error and reconcile the stale row.
func TestHandler_getPlan_NotFound_MapsTo404(t *testing.T) {
ctx := context.Background()
mockStore := new(MockConfigStore)
mockAuth := new(MockAuthService)

adminSession := &Session{UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"}
mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil)
mockAuth.grantAdmin()

planID := "12345678-1234-1234-1234-123456789abc"
// GetPurchasePlan wraps config.ErrNotFound exactly as the Postgres store does.
mockStore.On("GetPurchasePlan", ctx, planID).
Return(nil, fmt.Errorf("%w: purchase plan %s", config.ErrNotFound, planID))

handler := &Handler{config: mockStore, auth: mockAuth}

req := &events.LambdaFunctionURLRequest{
Headers: map[string]string{"Authorization": "Bearer admin-token"},
}
_, err := handler.getPlan(ctx, req, planID)

ce, ok := IsClientError(err)
require.True(t, ok, "a missing plan must map to a ClientError, not propagate raw (500)")
assert.Equal(t, 404, ce.code, "a missing plan must be 404, not 500")
mockStore.AssertExpectations(t)
}

func TestHandler_updatePlan(t *testing.T) {
ctx := context.Background()
mockStore := new(MockConfigStore)
Expand Down
Loading