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
34 changes: 34 additions & 0 deletions frontend/src/__tests__/plans.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,40 @@ describe('Plans Module', () => {
}
});

test('passes account_ids to api.getPlans when account filter is active (issue #705)', async () => {
// Regression test for the Account global filter being non-functional
// on the Plans page. loadPlans must forward the account selection to
// api.getPlans so the backend can JOIN plan_accounts and prune the list.
const state = await import('../state');
const accountID = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
(state.getCurrentAccountIDs as jest.Mock).mockReturnValue([accountID]);

(api.getPlans as jest.Mock).mockResolvedValue({ plans: [] });
(api.getPlannedPurchases as jest.Mock).mockResolvedValue({ purchases: [] });

try {
await loadPlans();

expect(api.getPlans).toHaveBeenCalledWith({ account_ids: [accountID] });
} finally {
(state.getCurrentAccountIDs as jest.Mock).mockReturnValue([]);
}
});

test('calls api.getPlans with empty object when no account is selected', async () => {
// When no account chip is active, getPlans receives {} so the backend
// returns all plans (no account_ids filter applied).
const state = await import('../state');
(state.getCurrentAccountIDs as jest.Mock).mockReturnValue([]);

(api.getPlans as jest.Mock).mockResolvedValue({ plans: [] });
(api.getPlannedPurchases as jest.Mock).mockResolvedValue({ purchases: [] });

await loadPlans();

expect(api.getPlans).toHaveBeenCalledWith({});
});

test('shows error on API failure', async () => {
(api.getPlans as jest.Mock).mockRejectedValue(new Error('API Error'));
(api.getPlannedPurchases as jest.Mock).mockResolvedValue({ purchases: [] });
Expand Down
18 changes: 14 additions & 4 deletions frontend/src/api/plans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,23 @@
*/

import { apiRequest } from './client';
import type { Plan, CreatePlanRequest } from './types';
import type { Plan, CreatePlanRequest, PlanFilters } from './types';

/**
* Get purchase plans
* Get purchase plans, optionally filtered by account IDs.
*
* When filters.account_ids is non-empty the backend returns only plans
* that reference at least one of those accounts via the plan_accounts
* join table. Mirrors the account_ids filtering pattern in
* getRecommendations (see recommendations.ts).
*/
export async function getPlans(): Promise<Plan[]> {
return apiRequest<Plan[]>('/plans');
export async function getPlans(filters: PlanFilters = {}): Promise<Plan[]> {
const params = new URLSearchParams();
if (filters.account_ids && filters.account_ids.length > 0) {
params.set('account_ids', filters.account_ids.join(','));
}
const queryString = params.toString();
return apiRequest<Plan[]>(`/plans${queryString ? '?' + queryString : ''}`);
}

/**
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,11 @@ export interface RecommendationFilters {
account_ids?: string[];
}

// PlanFilters are the query parameters accepted by the GET /api/plans endpoint.
export interface PlanFilters {
account_ids?: string[];
}

// Plan types
export interface PlanRampSchedule {
type: string;
Expand Down
10 changes: 9 additions & 1 deletion frontend/src/plans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,15 @@ export async function loadPlans(): Promise<void> {
if (newPlanBtn) newPlanBtn.hidden = !canAccess('create', 'plans');

try {
const data = await api.getPlans() as unknown as PlansResponse;
// Account filter: pass account_ids to the backend so it JOINs
// plan_accounts and returns only plans that reference one of the
// selected accounts. Empty array means "all plans" — the backend
// omits the JOIN entirely in that case. Mirrors the pattern used by
// getRecommendations (see recommendations.ts, issue #705).
const accountIDs = state.getCurrentAccountIDs();
const data = await api.getPlans(
accountIDs.length > 0 ? { account_ids: accountIDs } : {}
) as unknown as PlansResponse;
let plans = data.plans || [];

// Client-side provider filter. Backend `config.PurchasePlan` has no
Expand Down
2 changes: 1 addition & 1 deletion internal/analytics/collector_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ func (m *mockConfigStore) DeletePurchasePlan(ctx context.Context, planID string)
return nil
}

func (m *mockConfigStore) ListPurchasePlans(ctx context.Context) ([]config.PurchasePlan, error) {
func (m *mockConfigStore) ListPurchasePlans(ctx context.Context, filter config.PurchasePlanFilter) ([]config.PurchasePlan, error) {
return nil, nil
}

Expand Down
4 changes: 2 additions & 2 deletions internal/api/handler_coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -628,15 +628,15 @@ func TestHandler_listPlans_Error(t *testing.T) {

adminSession := &Session{UserID: "admin-id", Role: "admin"}
mockAuth.On("ValidateSession", ctx, "test-token").Return(adminSession, nil)
mockStore.On("ListPurchasePlans", mock.Anything).Return(nil, errors.New("db error"))
mockStore.On("ListPurchasePlans", mock.Anything, mock.Anything).Return(nil, errors.New("db error"))

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

req := &events.LambdaFunctionURLRequest{
Headers: map[string]string{"Authorization": "Bearer test-token"},
}

_, err := handler.listPlans(ctx, req)
_, err := handler.listPlans(ctx, req, map[string]string{})
assert.Error(t, err)
}

Expand Down
2 changes: 1 addition & 1 deletion internal/api/handler_dashboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ func (h *Handler) getUpcomingPurchases(ctx context.Context, req *events.LambdaFu
return nil, fmt.Errorf("failed to get pending executions: %w", err)
}

plans, err := h.config.ListPurchasePlans(ctx)
plans, err := h.config.ListPurchasePlans(ctx, config.PurchasePlanFilter{})
if err != nil {
return nil, fmt.Errorf("failed to get purchase plans: %w", err)
}
Expand Down
12 changes: 6 additions & 6 deletions internal/api/handler_dashboard_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,7 @@ func TestHandler_getUpcomingPurchases(t *testing.T) {
}

mockStore.On("GetPendingExecutions", ctx).Return(pending, nil)
mockStore.On("ListPurchasePlans", ctx).Return([]config.PurchasePlan{planA, planB}, nil)
mockStore.On("ListPurchasePlans", ctx, config.PurchasePlanFilter{}).Return([]config.PurchasePlan{planA, planB}, nil)

mockAuth, req := adminDashboardReq(ctx)
handler := &Handler{auth: mockAuth, config: mockStore}
Expand Down Expand Up @@ -350,7 +350,7 @@ func TestHandler_getUpcomingPurchases_OrphanExecutionSkipped(t *testing.T) {
},
}
mockStore.On("GetPendingExecutions", ctx).Return(pending, nil)
mockStore.On("ListPurchasePlans", ctx).Return([]config.PurchasePlan{}, nil)
mockStore.On("ListPurchasePlans", ctx, config.PurchasePlanFilter{}).Return([]config.PurchasePlan{}, nil)

mockAuth, req := adminDashboardReq(ctx)
handler := &Handler{auth: mockAuth, config: mockStore}
Expand Down Expand Up @@ -403,7 +403,7 @@ func TestHandler_getUpcomingPurchases_ScopedUser(t *testing.T) {
RampSchedule: config.RampSchedule{CurrentStep: 0, TotalSteps: 5},
}

mockStore.On("ListPurchasePlans", ctx).Return([]config.PurchasePlan{planA, planB}, nil)
mockStore.On("ListPurchasePlans", ctx, config.PurchasePlanFilter{}).Return([]config.PurchasePlan{planA, planB}, nil)
mockStore.On("GetPendingExecutions", ctx).Return([]config.PurchaseExecution{
{ExecutionID: "exec-A", PlanID: planA.ID, Status: "pending", ScheduledDate: nextExecDate, StepNumber: 1},
{ExecutionID: "exec-B", PlanID: planB.ID, Status: "pending", ScheduledDate: nextExecDate, StepNumber: 1},
Expand Down Expand Up @@ -453,7 +453,7 @@ func TestHandler_getUpcomingPurchases_ScopedUser_SkipsUnattributed(t *testing.T)
NextExecutionDate: &nextExecDate,
RampSchedule: config.RampSchedule{CurrentStep: 0, TotalSteps: 5},
}
mockStore.On("ListPurchasePlans", ctx).Return([]config.PurchasePlan{plan}, nil)
mockStore.On("ListPurchasePlans", ctx, config.PurchasePlanFilter{}).Return([]config.PurchasePlan{plan}, nil)
mockStore.On("GetPendingExecutions", ctx).Return([]config.PurchaseExecution{
{ExecutionID: "exec-unattributed", PlanID: plan.ID, Status: "pending", ScheduledDate: nextExecDate, StepNumber: 1},
}, nil)
Expand Down Expand Up @@ -793,7 +793,7 @@ func TestHandler_getUpcomingPurchases_Errors(t *testing.T) {
t.Run("list plans error", func(t *testing.T) {
mockStore := new(MockConfigStore)
mockStore.On("GetPendingExecutions", ctx).Return([]config.PurchaseExecution{}, nil)
mockStore.On("ListPurchasePlans", ctx).Return(nil, errors.New("db error"))
mockStore.On("ListPurchasePlans", ctx, config.PurchasePlanFilter{}).Return(nil, errors.New("db error"))

mockAuth, req := adminDashboardReq(ctx)
handler := &Handler{auth: mockAuth, config: mockStore}
Expand All @@ -806,7 +806,7 @@ func TestHandler_getUpcomingPurchases_Errors(t *testing.T) {
t.Run("no pending executions yields empty list", func(t *testing.T) {
mockStore := new(MockConfigStore)
mockStore.On("GetPendingExecutions", ctx).Return([]config.PurchaseExecution{}, nil)
mockStore.On("ListPurchasePlans", ctx).Return([]config.PurchasePlan{}, nil)
mockStore.On("ListPurchasePlans", ctx, config.PurchasePlanFilter{}).Return([]config.PurchasePlan{}, nil)

mockAuth, req := adminDashboardReq(ctx)
handler := &Handler{auth: mockAuth, config: mockStore}
Expand Down
12 changes: 10 additions & 2 deletions internal/api/handler_plans.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,21 @@ import (
)

// Plans handlers
func (h *Handler) listPlans(ctx context.Context, req *events.LambdaFunctionURLRequest) (*PlansResponse, error) {
func (h *Handler) listPlans(ctx context.Context, req *events.LambdaFunctionURLRequest, params map[string]string) (*PlansResponse, error) {
// Require view:plans permission
if _, err := h.requirePermission(ctx, req, "view", "plans"); err != nil {
return nil, err
}

plans, err := h.config.ListPurchasePlans(ctx)
// parseAccountIDs validates and splits the comma-separated account_ids
// query param. Returns nil (no filter) when absent or empty.
accountIDs, err := parseAccountIDs(params["account_ids"])
if err != nil {
return nil, NewClientError(400, err.Error())
}

filter := config.PurchasePlanFilter{AccountIDs: accountIDs}
plans, err := h.config.ListPurchasePlans(ctx, filter)
if err != nil {
return nil, err
}
Expand Down
40 changes: 38 additions & 2 deletions internal/api/handler_plans_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ func TestHandler_listPlans(t *testing.T) {
}

mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil)
mockStore.On("ListPurchasePlans", ctx).Return(plans, nil)
mockStore.On("ListPurchasePlans", ctx, config.PurchasePlanFilter{}).Return(plans, nil)

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

Expand All @@ -40,12 +40,48 @@ func TestHandler_listPlans(t *testing.T) {
"Authorization": "Bearer admin-token",
},
}
result, err := handler.listPlans(ctx, req)
result, err := handler.listPlans(ctx, req, map[string]string{})
require.NoError(t, err)

assert.Len(t, result.Plans, 2)
}

func TestHandler_listPlans_AccountIDsFilter(t *testing.T) {
ctx := context.Background()
mockStore := new(MockConfigStore)
mockAuth := new(MockAuthService)

adminSession := &Session{
UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
Email: "admin@example.com",
Role: "admin",
}

plans := []config.PurchasePlan{
{ID: "11111111-1111-1111-1111-111111111111", Name: "Account Plan", Enabled: true},
}

accountID := "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
expectedFilter := config.PurchasePlanFilter{AccountIDs: []string{accountID}}

mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil)
mockStore.On("ListPurchasePlans", ctx, expectedFilter).Return(plans, nil)

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

req := &events.LambdaFunctionURLRequest{
Headers: map[string]string{
"Authorization": "Bearer admin-token",
},
}
params := map[string]string{"account_ids": accountID}
result, err := handler.listPlans(ctx, req, params)
require.NoError(t, err)

assert.Len(t, result.Plans, 1)
assert.Equal(t, "Account Plan", result.Plans[0].Name)
}

func TestHandler_createPlan(t *testing.T) {
ctx := context.Background()
mockStore := new(MockConfigStore)
Expand Down
2 changes: 1 addition & 1 deletion internal/api/handler_purchases.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ func (h *Handler) getPlannedPurchases(ctx context.Context, req *events.LambdaFun
return nil, fmt.Errorf("failed to get pending executions: %w", err)
}

plans, err := h.config.ListPurchasePlans(ctx)
plans, err := h.config.ListPurchasePlans(ctx, config.PurchasePlanFilter{})
if err != nil {
return nil, fmt.Errorf("failed to get purchase plans: %w", err)
}
Expand Down
4 changes: 2 additions & 2 deletions internal/api/handler_purchases_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -752,7 +752,7 @@ func TestHandler_getPlannedPurchases(t *testing.T) {

mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil)
mockStore.On("GetPendingExecutions", ctx).Return(executions, nil)
mockStore.On("ListPurchasePlans", ctx).Return(plans, nil)
mockStore.On("ListPurchasePlans", ctx, config.PurchasePlanFilter{}).Return(plans, nil)

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

Expand Down Expand Up @@ -1069,7 +1069,7 @@ func TestHandler_getPlannedPurchases_ErrorGettingPlans(t *testing.T) {

mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil)
mockStore.On("GetPendingExecutions", ctx).Return(executions, nil)
mockStore.On("ListPurchasePlans", ctx).Return(nil, errors.New("database error"))
mockStore.On("ListPurchasePlans", ctx, config.PurchasePlanFilter{}).Return(nil, errors.New("database error"))

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

Expand Down
8 changes: 4 additions & 4 deletions internal/api/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -544,7 +544,7 @@ func TestHandler_HandleRequest_ListPlans(t *testing.T) {
mockAuth.On("ValidateSession", ctx, "test-token").Return(adminSession, nil)

plans := []config.PurchasePlan{{ID: "11111111-1111-1111-1111-111111111111"}}
mockStore.On("ListPurchasePlans", mock.Anything).Return(plans, nil)
mockStore.On("ListPurchasePlans", mock.Anything, mock.Anything).Return(plans, nil)

handler := &Handler{config: mockStore, auth: mockAuth, apiKey: "test-key"}

Expand Down Expand Up @@ -953,7 +953,7 @@ func TestHandler_HandleRequest_GetUpcomingPurchases(t *testing.T) {
},
}

mockStore.On("ListPurchasePlans", ctx).Return(plans, nil)
mockStore.On("ListPurchasePlans", ctx, config.PurchasePlanFilter{}).Return(plans, nil)
// New: handler now enumerates pending executions per PR #213. Fixture
// supplies one pending exec for the plan above so the integration test
// still observes a single upcoming row.
Expand Down Expand Up @@ -1012,7 +1012,7 @@ func TestHandler_HandleRequest_GetPlannedPurchases(t *testing.T) {
}

mockStore.On("GetPendingExecutions", ctx).Return(executions, nil)
mockStore.On("ListPurchasePlans", ctx).Return(plans, nil)
mockStore.On("ListPurchasePlans", ctx, config.PurchasePlanFilter{}).Return(plans, nil)

handler := &Handler{config: mockStore, auth: mockAuth, corsAllowedOrigin: "*", apiKey: "test-key"}

Expand Down Expand Up @@ -1289,7 +1289,7 @@ func TestHandler_HandleRequest_ListPlans_Error(t *testing.T) {
adminSession := &Session{UserID: "admin-id", Email: "admin@example.com", Role: "admin"}
mockAuth.On("ValidateSession", ctx, "test-token").Return(adminSession, nil)

mockStore.On("ListPurchasePlans", mock.Anything).Return(nil, assert.AnError)
mockStore.On("ListPurchasePlans", mock.Anything, mock.Anything).Return(nil, assert.AnError)

handler := &Handler{config: mockStore, auth: mockAuth, apiKey: "test-key"}

Expand Down
4 changes: 2 additions & 2 deletions internal/api/mocks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,8 +138,8 @@ func (m *MockConfigStore) DeletePurchasePlan(ctx context.Context, planID string)
return args.Error(0)
}

func (m *MockConfigStore) ListPurchasePlans(ctx context.Context) ([]config.PurchasePlan, error) {
args := m.Called(ctx)
func (m *MockConfigStore) ListPurchasePlans(ctx context.Context, filter config.PurchasePlanFilter) ([]config.PurchasePlan, error) {
args := m.Called(ctx, filter)
if args.Get(0) == nil {
return nil, args.Error(1)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/api/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -432,7 +432,7 @@ func (r *Router) getRecommendationDetailHandler(ctx context.Context, req *events
}

func (r *Router) listPlansHandler(ctx context.Context, req *events.LambdaFunctionURLRequest, params map[string]string) (any, error) {
return r.h.listPlans(ctx, req)
return r.h.listPlans(ctx, req, req.QueryStringParameters)
}

func (r *Router) createPlanHandler(ctx context.Context, req *events.LambdaFunctionURLRequest, params map[string]string) (any, error) {
Expand Down
2 changes: 1 addition & 1 deletion internal/config/interfaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ type StoreInterface interface {
// rows and no stale plan pointer.
UpdatePurchasePlanTx(ctx context.Context, tx pgx.Tx, plan *PurchasePlan) error
DeletePurchasePlan(ctx context.Context, planID string) error
ListPurchasePlans(ctx context.Context) ([]PurchasePlan, error)
ListPurchasePlans(ctx context.Context, filter PurchasePlanFilter) ([]PurchasePlan, error)

// Purchase executions
SavePurchaseExecution(ctx context.Context, execution *PurchaseExecution) error
Expand Down
Loading
Loading