From c9454d7181a027569f54f4ee4dec26fa0582b024 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 6 Oct 2026 21:02:36 +0200 Subject: [PATCH 1/4] fix(history): signal when the history fetch hit its row cap The history API capped completed rows and non-completed executions at the row limit with no indication, so pending approvals and summary totals could silently under-report. Add truncated and limit to the response and show a banner above the approval queue and the history table. Refs #248 --- .../history-truncation-notice.test.ts | 101 ++++++++++++++ frontend/src/history.ts | 19 +++ frontend/src/index.html | 2 + frontend/src/types.ts | 4 + internal/api/handler_history.go | 19 ++- .../api/handler_history_truncation_test.go | 124 ++++++++++++++++++ internal/api/openapi.yaml | 9 ++ internal/api/types.go | 6 + 8 files changed, 279 insertions(+), 5 deletions(-) create mode 100644 frontend/src/__tests__/history-truncation-notice.test.ts create mode 100644 internal/api/handler_history_truncation_test.go diff --git a/frontend/src/__tests__/history-truncation-notice.test.ts b/frontend/src/__tests__/history-truncation-notice.test.ts new file mode 100644 index 00000000..dd997663 --- /dev/null +++ b/frontend/src/__tests__/history-truncation-notice.test.ts @@ -0,0 +1,101 @@ +/** + * History truncation banner (issue #248): the API flags `truncated` when a + * fetch hit its row cap; the UI must say so above the queue and the table. + */ + +import { loadHistory } from '../history'; + +jest.mock('../api', () => ({ + getHistory: jest.fn(), + getConfig: jest.fn().mockResolvedValue({ global: {} }), + listAccounts: jest.fn().mockResolvedValue([]), + getDeploymentInfo: jest.fn().mockResolvedValue({}), +})); +jest.mock('../navigation', () => ({ switchTab: jest.fn() })); +jest.mock('../utils', () => ({ + formatCurrency: jest.fn((val) => `$${val || 0}`), + formatDate: jest.fn((val) => (val ? new Date(val).toLocaleDateString() : '')), + formatTerm: jest.fn((years) => (years == null ? '' : `${years} Year${years === 1 ? '' : 's'}`)), + escapeHtml: jest.fn((str) => str || ''), + escapeHtmlAttr: jest.fn((str: string | null | undefined) => str || ''), + populateAccountFilter: jest.fn(() => Promise.resolve()), +})); +jest.mock('../confirmDialog', () => ({ confirmDialog: jest.fn() })); +jest.mock('../toast', () => ({ showToast: jest.fn() })); +jest.mock('../state', () => ({ + getCurrentUser: jest.fn(), + getCurrentProvider: jest.fn().mockReturnValue(''), + setCurrentProvider: jest.fn(), + getCurrentAccountIDs: jest.fn().mockReturnValue([]), + setCurrentAccountIDs: jest.fn(), + subscribeProvider: jest.fn().mockReturnValue(() => {}), + subscribeAccount: jest.fn().mockReturnValue(() => {}), + getAmortizeUpfront: jest.fn().mockReturnValue(false), + setAmortizeUpfront: jest.fn(), + subscribeAmortizeUpfront: jest.fn().mockReturnValue(() => {}), + getPurchaseHistoryColumnFilters: jest.fn().mockReturnValue({}), + setPurchaseHistoryColumnFilter: jest.fn(), + clearAllPurchaseHistoryColumnFilters: jest.fn(), + getApprovalQueueColumnFilters: jest.fn().mockReturnValue({}), + setApprovalQueueColumnFilter: jest.fn(), + clearAllApprovalQueueColumnFilters: jest.fn(), +})); +jest.mock('../recommendations', () => ({ getAccountName: jest.fn((id: string) => id) })); + +import * as api from '../api'; + +function setupDOM(): void { + while (document.body.firstChild) document.body.removeChild(document.body.firstChild); + const ids = ['history-start', 'history-end']; + for (const id of ids) { + const el = document.createElement('input'); + el.type = 'date'; + el.id = id; + document.body.appendChild(el); + } + for (const id of ['history-summary', 'history-list', 'purchases-approval-queue']) { + const el = document.createElement('div'); + el.id = id; + document.body.appendChild(el); + } + for (const id of ['purchases-truncation-notice', 'history-truncation-notice']) { + const el = document.createElement('p'); + el.id = id; + el.hidden = true; + document.body.appendChild(el); + } +} + +describe('History truncation banner (issue #248)', () => { + beforeEach(() => { + setupDOM(); + jest.clearAllMocks(); + }); + + test('shows the banner above both tables when the API reports truncation', async () => { + (api.getHistory as jest.Mock).mockResolvedValue({ summary: {}, purchases: [], truncated: true, limit: 100 }); + await loadHistory(); + for (const id of ['purchases-truncation-notice', 'history-truncation-notice']) { + const el = document.getElementById(id)!; + expect(el.hidden).toBe(false); + expect(el.textContent).toContain('100'); + } + }); + + test('keeps the banner hidden when the response is not truncated', async () => { + (api.getHistory as jest.Mock).mockResolvedValue({ summary: {}, purchases: [], truncated: false, limit: 100 }); + await loadHistory(); + for (const id of ['purchases-truncation-notice', 'history-truncation-notice']) { + expect(document.getElementById(id)!.hidden).toBe(true); + } + }); + + test('clears a previous banner on a later untruncated load', async () => { + (api.getHistory as jest.Mock).mockResolvedValueOnce({ summary: {}, purchases: [], truncated: true, limit: 100 }); + await loadHistory(); + (api.getHistory as jest.Mock).mockResolvedValueOnce({ summary: {}, purchases: [] }); + await loadHistory(); + expect(document.getElementById('history-truncation-notice')!.hidden).toBe(true); + expect(document.getElementById('history-truncation-notice')!.textContent).toBe(''); + }); +}); diff --git a/frontend/src/history.ts b/frontend/src/history.ts index 3de24240..5aaba99d 100644 --- a/frontend/src/history.ts +++ b/frontend/src/history.ts @@ -270,6 +270,7 @@ export async function viewPlanHistory(planId: string): Promise { ]); if (requestEpoch !== historyMutationEpoch) return; renderHistorySummary(data.summary ?? null); + renderTruncationNotices(data); const purchases = data.purchases || []; renderApprovalQueue(purchases); renderHistoryList(purchases); @@ -362,6 +363,7 @@ export async function loadHistory(): Promise { ]); if (requestEpoch !== historyMutationEpoch) return; renderHistorySummary(data.summary ?? null); + renderTruncationNotices(data); const purchases = data.purchases || []; renderApprovalQueue(purchases); renderHistoryList(purchases); @@ -382,6 +384,23 @@ export async function loadHistory(): Promise { } } +/** + * Show or hide the "results capped" banner above the approval queue and the + * history table. The API sets `truncated` when a fetch hit its row cap, in + * which case pending approvals and the summary totals can under-report. + */ +function renderTruncationNotices(data: HistoryResponse): void { + const message = data.truncated + ? `Showing the most recent ${data.limit ?? 'capped'} rows per source; older rows are not shown, so pending approvals and totals may be incomplete. Narrow the date range or filters to see them.` + : ''; + for (const id of ['purchases-truncation-notice', 'history-truncation-notice']) { + const el = document.getElementById(id); + if (!el) continue; + el.textContent = message; + el.hidden = !data.truncated; + } +} + function renderHistorySummary(summary: HistorySummary | null): void { const container = document.getElementById('history-summary'); if (!container) return; diff --git a/frontend/src/index.html b/frontend/src/index.html index 5e41088b..34acdfc0 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -180,6 +180,7 @@

Planned Purchases

Approval queue

Purchases awaiting approval. Review and approve or cancel each one. The same actions are available from the Purchase History table below.

+
@@ -249,6 +250,7 @@

Purchase History

+
diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 89663abb..c7ef51b9 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -193,6 +193,10 @@ export interface SavePlanData { export interface HistoryResponse { summary?: HistorySummary; purchases?: HistoryPurchase[]; + /** True when a fetch hit its row cap, so older rows may be missing. */ + truncated?: boolean; + /** Per-source row cap in effect for the request. */ + limit?: number; } export interface HistorySummary { diff --git a/internal/api/handler_history.go b/internal/api/handler_history.go index 4f2e0e5f..54fce113 100644 --- a/internal/api/handler_history.go +++ b/internal/api/handler_history.go @@ -61,7 +61,7 @@ func (h *Handler) getHistory(ctx context.Context, req *events.LambdaFunctionURLR // status. On servers the transitions fire in a goroutine; on Lambda they // run synchronously before returning because the execution environment // freezes once the response is out (issue #1170). - extra, staleExecs := h.fetchExecutionsAsHistory(ctx, filters) + extra, staleExecs, executionsCapped := h.fetchExecutionsAsHistory(ctx, filters) h.expireStaleExecutions(staleExecs) all := make([]config.PurchaseHistoryRecord, 0, len(completed)+len(extra)) @@ -79,9 +79,14 @@ func (h *Handler) getHistory(ctx context.Context, req *events.LambdaFunctionURLR return all[i].Timestamp.After(all[j].Timestamp) }) + // Truncation is judged on the raw fetches (before the in-memory and scope + // filters) because the cap drops rows before they run. It is a single + // boolean: a count would reveal other tenants' row volume to a scoped user. return HistoryResponse{ Summary: summarizePurchaseHistory(all), Purchases: all, + Truncated: len(completed) >= filters.Limit || executionsCapped, + Limit: filters.Limit, }, nil } @@ -145,14 +150,18 @@ const approvalExpiryWindow = 7 * 24 * time.Hour // The filter set (issue #701) is applied in Go against the synthetic row: // provider via the recs' collapsed provider, account via CloudAccountID, // date via ScheduledDate. -func (h *Handler) fetchExecutionsAsHistory(ctx context.Context, filters historyFilters) ([]config.PurchaseHistoryRecord, []config.PurchaseExecution) { +// +// The third return value reports that the fetch reached its cap, so older +// executions may have been dropped before the filters ran. +func (h *Handler) fetchExecutionsAsHistory(ctx context.Context, filters historyFilters) ([]config.PurchaseHistoryRecord, []config.PurchaseExecution, bool) { executions, err := h.config.GetExecutionsByStatuses(ctx, historyExecutionStatuses, config.DefaultListLimit) if err != nil { logging.Warnf("history: failed to load non-completed executions: %v", err) - return nil, nil + return nil, nil, false } + capped := len(executions) >= config.DefaultListLimit if len(executions) == 0 { - return nil, nil + return nil, nil, false } approver := h.resolvePendingApproverEmail(ctx) userEmailCache := h.resolveUserEmails(ctx, executions) @@ -184,7 +193,7 @@ func (h *Handler) fetchExecutionsAsHistory(ctx context.Context, filters historyF } out = append(out, executionToHistoryRow(exec, approver, createdByEmail)) } - return out, staleExecs + return out, staleExecs, capped } // isStaleExecution reports whether the execution is a pending/notified diff --git a/internal/api/handler_history_truncation_test.go b/internal/api/handler_history_truncation_test.go new file mode 100644 index 00000000..1817096e --- /dev/null +++ b/internal/api/handler_history_truncation_test.go @@ -0,0 +1,124 @@ +package api + +import ( + "context" + "encoding/json" + "fmt" + "testing" + "time" + + "github.com/LeanerCloud/cloud-commitments-platform/internal/config" + "github.com/aws/aws-lambda-go/events" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func historyRows(n int) []config.PurchaseHistoryRecord { + rows := make([]config.PurchaseHistoryRecord, n) + for i := range rows { + rows[i] = config.PurchaseHistoryRecord{PurchaseID: fmt.Sprintf("p-%d", i), Timestamp: time.Now()} + } + return rows +} + +func pendingExecutions(n int, creatorID *string) []config.PurchaseExecution { + execs := make([]config.PurchaseExecution, n) + for i := range execs { + execs[i] = config.PurchaseExecution{ + ExecutionID: fmt.Sprintf("e-%d", i), + Status: "pending", + ScheduledDate: time.Now(), + CreatedByUserID: creatorID, + } + } + return execs +} + +// historyJSON round-trips the response through JSON so the assertion sees the +// wire shape a client sees, not the Go struct. +func historyJSON(t *testing.T, resp any) map[string]any { + t.Helper() + raw, err := json.Marshal(resp) + require.NoError(t, err) + var out map[string]any + require.NoError(t, json.Unmarshal(raw, &out)) + return out +} + +func runAdminHistory(t *testing.T, completed []config.PurchaseHistoryRecord, execs []config.PurchaseExecution, params map[string]string) map[string]any { + t.Helper() + ctx := context.Background() + mockStore := new(MockConfigStore) + mockStore.On("GetAllPurchaseHistory", ctx, mock.Anything).Return(completed, nil) + mockStore.On("GetExecutionsByStatuses", ctx, mock.Anything, mock.Anything).Return(execs, nil) + mockStore.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{}, nil).Maybe() + mockAuth, req := adminHistoryReq(ctx) + handler := &Handler{auth: mockAuth, config: mockStore} + + result, err := handler.getHistory(ctx, req, params) + require.NoError(t, err) + return historyJSON(t, result) +} + +func TestHandler_getHistory_TruncatedWhenCompletedAtCap(t *testing.T) { + body := runAdminHistory(t, historyRows(config.DefaultListLimit), nil, map[string]string{}) + assert.Equal(t, true, body["truncated"], "a full page of completed rows may hide older ones") + assert.EqualValues(t, config.DefaultListLimit, body["limit"]) +} + +func TestHandler_getHistory_TruncatedWhenExecutionsAtCap(t *testing.T) { + // The execution fetch is capped before the in-memory filters run, so a + // full page means pending approvals may have been dropped. + body := runAdminHistory(t, nil, pendingExecutions(config.DefaultListLimit, nil), map[string]string{}) + assert.Equal(t, true, body["truncated"]) +} + +func TestHandler_getHistory_TruncatedHonorsCustomLimit(t *testing.T) { + body := runAdminHistory(t, historyRows(5), nil, map[string]string{"limit": "5"}) + assert.Equal(t, true, body["truncated"]) + assert.EqualValues(t, 5, body["limit"]) +} + +func TestHandler_getHistory_NotTruncatedUnderLimit(t *testing.T) { + body := runAdminHistory(t, historyRows(config.DefaultListLimit-1), pendingExecutions(config.DefaultListLimit-1, nil), map[string]string{}) + assert.Equal(t, false, body["truncated"]) + assert.EqualValues(t, config.DefaultListLimit, body["limit"]) +} + +// A scoped user's visible rows can be few while the fetch hit the cap: the +// flag is derived from the pre-scope fetch (the cap drops rows before scope +// filtering) but must expose only a boolean, never other tenants' counts. +func TestHandler_getHistory_ScopedUserTruncationLeaksNoCounts(t *testing.T) { + ctx := context.Background() + const scopedUserID = "scoped-user-id" + own := scopedUserID + execs := pendingExecutions(config.DefaultListLimit, nil) + execs[0].CreatedByUserID = &own + + mockStore := new(MockConfigStore) + mockStore.On("GetAllPurchaseHistory", ctx, mock.Anything).Return([]config.PurchaseHistoryRecord{}, nil) + mockStore.On("GetExecutionsByStatuses", ctx, mock.Anything, mock.Anything).Return(execs, nil) + mockStore.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{}, nil).Maybe() + mockStore.ListCloudAccountsFn = func(_ context.Context, _ config.CloudAccountFilter) ([]config.CloudAccount, error) { + return nil, nil + } + mockAuth := new(MockAuthService) + mockAuth.On("ValidateSession", ctx, "scoped-token").Return(&Session{UserID: scopedUserID, Email: "s@example.com"}, nil) + mockAuth.On("HasPermissionAPI", ctx, scopedUserID, "view", "purchases").Return(true, nil) + mockAuth.On("GetAllowedAccountsAPI", ctx, scopedUserID).Return([]string{"aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa"}, nil) + mockAuth.On("GetUser", ctx, mock.Anything).Return(&User{Email: "s@example.com"}, nil).Maybe() + handler := &Handler{auth: mockAuth, config: mockStore} + req := &events.LambdaFunctionURLRequest{Headers: map[string]string{"Authorization": "Bearer scoped-token"}} + + result, err := handler.getHistory(ctx, req, map[string]string{}) + require.NoError(t, err) + body := historyJSON(t, result) + + assert.Len(t, body["purchases"], 1, "scoped user sees only their own row") + assert.Equal(t, true, body["truncated"]) + assert.NotContains(t, body, "total", "no count of rows outside the caller's scope may be exposed") + summary, ok := body["summary"].(map[string]any) + require.True(t, ok) + assert.EqualValues(t, 1, summary["total_purchases"]) +} diff --git a/internal/api/openapi.yaml b/internal/api/openapi.yaml index ec8a7ab3..56708094 100644 --- a/internal/api/openapi.yaml +++ b/internal/api/openapi.yaml @@ -3429,6 +3429,15 @@ components: type: array items: $ref: '#/components/schemas/PurchaseHistoryRecord' + truncated: + type: boolean + description: >- + True when a fetch reached its row cap, so older rows (and possibly + pending approvals) may be missing from purchases and summary. May + be a false positive when exactly `limit` rows exist. + limit: + type: integer + description: Per-source row cap in effect for this request. HistorySummary: type: object diff --git a/internal/api/types.go b/internal/api/types.go index 709fd4bf..b43d54dd 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -922,6 +922,12 @@ type CreatePlannedPurchasesResponse struct { type HistoryResponse struct { Purchases []config.PurchaseHistoryRecord `json:"purchases"` Summary HistorySummary `json:"summary"` + // Truncated is true when a fetch reached its row cap, so older rows (and + // possibly pending approvals) may be missing from Purchases and Summary. + // It can be a false positive when exactly Limit rows exist. + Truncated bool `json:"truncated"` + // Limit is the per-source row cap in effect for this request. + Limit int `json:"limit"` } // HistorySummary provides aggregate statistics for purchase history. From 3d5d51a339f9bbffe315d40d5aee23db2057ac5a Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 6 Oct 2026 21:15:22 +0200 Subject: [PATCH 2/4] fix(history): count only surfaceable executions toward the cap signal Exclude clean completed executions in the store query and from the cap count so mature tenants do not see a permanent banner. Report the fixed executions_limit separately from limit, make the banner text accurate, and clear it when a load fails. --- .../history-truncation-notice.test.ts | 15 ++++++- frontend/src/history.ts | 4 +- frontend/src/types.ts | 4 +- internal/api/handler_history.go | 16 +++++--- .../api/handler_history_truncation_test.go | 41 +++++++++++++++++++ internal/api/openapi.yaml | 7 +++- internal/api/types.go | 7 +++- internal/config/store_postgres.go | 5 +++ 8 files changed, 85 insertions(+), 14 deletions(-) diff --git a/frontend/src/__tests__/history-truncation-notice.test.ts b/frontend/src/__tests__/history-truncation-notice.test.ts index dd997663..55eb3a0c 100644 --- a/frontend/src/__tests__/history-truncation-notice.test.ts +++ b/frontend/src/__tests__/history-truncation-notice.test.ts @@ -73,7 +73,7 @@ describe('History truncation banner (issue #248)', () => { }); test('shows the banner above both tables when the API reports truncation', async () => { - (api.getHistory as jest.Mock).mockResolvedValue({ summary: {}, purchases: [], truncated: true, limit: 100 }); + (api.getHistory as jest.Mock).mockResolvedValue({ summary: {}, purchases: [], truncated: true, limit: 100, executions_limit: 100 }); await loadHistory(); for (const id of ['purchases-truncation-notice', 'history-truncation-notice']) { const el = document.getElementById(id)!; @@ -91,11 +91,22 @@ describe('History truncation banner (issue #248)', () => { }); test('clears a previous banner on a later untruncated load', async () => { - (api.getHistory as jest.Mock).mockResolvedValueOnce({ summary: {}, purchases: [], truncated: true, limit: 100 }); + (api.getHistory as jest.Mock).mockResolvedValueOnce({ summary: {}, purchases: [], truncated: true, limit: 100, executions_limit: 100 }); await loadHistory(); (api.getHistory as jest.Mock).mockResolvedValueOnce({ summary: {}, purchases: [] }); await loadHistory(); expect(document.getElementById('history-truncation-notice')!.hidden).toBe(true); expect(document.getElementById('history-truncation-notice')!.textContent).toBe(''); }); + + test('hides a previous banner when the next load fails', async () => { + (api.getHistory as jest.Mock).mockResolvedValueOnce({ summary: {}, purchases: [], truncated: true, limit: 100, executions_limit: 100 }); + await loadHistory(); + (api.getHistory as jest.Mock).mockRejectedValueOnce(new Error('boom')); + jest.spyOn(console, 'error').mockImplementation(() => {}); + await loadHistory(); + for (const id of ['purchases-truncation-notice', 'history-truncation-notice']) { + expect(document.getElementById(id)!.hidden).toBe(true); + } + }); }); diff --git a/frontend/src/history.ts b/frontend/src/history.ts index 5aaba99d..7e7ab4a8 100644 --- a/frontend/src/history.ts +++ b/frontend/src/history.ts @@ -278,6 +278,7 @@ export async function viewPlanHistory(planId: string): Promise { } catch (error) { if (requestEpoch !== historyMutationEpoch) return; console.error('Failed to load plan history:', error); + renderTruncationNotices({}); const err = error as Error; const list = document.getElementById('history-list'); if (list) { @@ -370,6 +371,7 @@ export async function loadHistory(): Promise { } catch (error) { if (requestEpoch !== historyMutationEpoch) return; console.error('Failed to load history:', error); + renderTruncationNotices({}); const err = error as Error; const list = document.getElementById('history-list'); if (list) { @@ -391,7 +393,7 @@ export async function loadHistory(): Promise { */ function renderTruncationNotices(data: HistoryResponse): void { const message = data.truncated - ? `Showing the most recent ${data.limit ?? 'capped'} rows per source; older rows are not shown, so pending approvals and totals may be incomplete. Narrow the date range or filters to see them.` + ? `Results are capped at the most recent ${data.limit ?? '?'} completed purchases and ${data.executions_limit ?? '?'} pending, failed or other non-completed executions. Older rows are not shown, so pending approvals and totals may be incomplete. Narrow the date range or filters to see them.` : ''; for (const id of ['purchases-truncation-notice', 'history-truncation-notice']) { const el = document.getElementById(id); diff --git a/frontend/src/types.ts b/frontend/src/types.ts index c7ef51b9..f1bd751e 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -195,8 +195,10 @@ export interface HistoryResponse { purchases?: HistoryPurchase[]; /** True when a fetch hit its row cap, so older rows may be missing. */ truncated?: boolean; - /** Per-source row cap in effect for the request. */ + /** Row cap on completed purchase history rows for the request. */ limit?: number; + /** Fixed row cap on non-completed executions. */ + executions_limit?: number; } export interface HistorySummary { diff --git a/internal/api/handler_history.go b/internal/api/handler_history.go index 54fce113..e1d8f999 100644 --- a/internal/api/handler_history.go +++ b/internal/api/handler_history.go @@ -83,10 +83,11 @@ func (h *Handler) getHistory(ctx context.Context, req *events.LambdaFunctionURLR // filters) because the cap drops rows before they run. It is a single // boolean: a count would reveal other tenants' row volume to a scoped user. return HistoryResponse{ - Summary: summarizePurchaseHistory(all), - Purchases: all, - Truncated: len(completed) >= filters.Limit || executionsCapped, - Limit: filters.Limit, + Summary: summarizePurchaseHistory(all), + Purchases: all, + Truncated: len(completed) >= filters.Limit || executionsCapped, + Limit: filters.Limit, + ExecutionsLimit: config.DefaultListLimit, }, nil } @@ -159,7 +160,6 @@ func (h *Handler) fetchExecutionsAsHistory(ctx context.Context, filters historyF logging.Warnf("history: failed to load non-completed executions: %v", err) return nil, nil, false } - capped := len(executions) >= config.DefaultListLimit if len(executions) == 0 { return nil, nil, false } @@ -167,6 +167,9 @@ func (h *Handler) fetchExecutionsAsHistory(ctx context.Context, filters historyF userEmailCache := h.resolveUserEmails(ctx, executions) out := make([]config.PurchaseHistoryRecord, 0, len(executions)) var staleExecs []config.PurchaseExecution + // Rows the History view can surface; clean completed executions do not + // count toward the cap signal (the store already excludes them). + surfaced := 0 for _rvc := range executions { exec := executions[_rvc] // Dedup: a normal completed execution is already represented by its @@ -177,6 +180,7 @@ func (h *Handler) fetchExecutionsAsHistory(ctx context.Context, filters historyF if exec.Status == "completed" && exec.Error == "" { continue } + surfaced++ // Collect stale pending/notified executions for the post-assembly // expire sweep. We do NOT mutate status here to keep the GET // read-only: the response reflects current DB state; the sweep @@ -193,7 +197,7 @@ func (h *Handler) fetchExecutionsAsHistory(ctx context.Context, filters historyF } out = append(out, executionToHistoryRow(exec, approver, createdByEmail)) } - return out, staleExecs, capped + return out, staleExecs, surfaced >= config.DefaultListLimit } // isStaleExecution reports whether the execution is a pending/notified diff --git a/internal/api/handler_history_truncation_test.go b/internal/api/handler_history_truncation_test.go index 1817096e..78e9677d 100644 --- a/internal/api/handler_history_truncation_test.go +++ b/internal/api/handler_history_truncation_test.go @@ -74,6 +74,47 @@ func TestHandler_getHistory_TruncatedWhenExecutionsAtCap(t *testing.T) { assert.Equal(t, true, body["truncated"]) } +func cleanCompletedExecutions(n int) []config.PurchaseExecution { + execs := pendingExecutions(n, nil) + for i := range execs { + execs[i].Status = "completed" + } + return execs +} + +// Clean completed executions are skipped by the handler (and excluded by the +// store query), so a mature tenant must not see a permanent banner. +func TestHandler_getHistory_NotTruncatedByCleanCompletedExecutions(t *testing.T) { + body := runAdminHistory(t, historyRows(3), cleanCompletedExecutions(config.DefaultListLimit), map[string]string{}) + assert.Equal(t, false, body["truncated"]) + assert.Len(t, body["purchases"], 3) +} + +func TestHandler_getHistory_TruncatedByFailedExecutions(t *testing.T) { + execs := pendingExecutions(config.DefaultListLimit, nil) + for i := range execs { + execs[i].Status = "failed" + } + body := runAdminHistory(t, nil, execs, map[string]string{}) + assert.Equal(t, true, body["truncated"]) +} + +func TestHandler_getHistory_AuditGapCompletedExecutionsCountTowardCap(t *testing.T) { + execs := cleanCompletedExecutions(config.DefaultListLimit) + for i := range execs { + execs[i].Error = "history write failed" + } + body := runAdminHistory(t, nil, execs, map[string]string{}) + assert.Equal(t, true, body["truncated"]) +} + +func TestHandler_getHistory_ExecutionsLimitIsFixed(t *testing.T) { + body := runAdminHistory(t, historyRows(2), pendingExecutions(config.DefaultListLimit, nil), map[string]string{"limit": "500"}) + assert.EqualValues(t, 500, body["limit"]) + assert.EqualValues(t, config.DefaultListLimit, body["executions_limit"]) + assert.Equal(t, true, body["truncated"]) +} + func TestHandler_getHistory_TruncatedHonorsCustomLimit(t *testing.T) { body := runAdminHistory(t, historyRows(5), nil, map[string]string{"limit": "5"}) assert.Equal(t, true, body["truncated"]) diff --git a/internal/api/openapi.yaml b/internal/api/openapi.yaml index 56708094..0669b705 100644 --- a/internal/api/openapi.yaml +++ b/internal/api/openapi.yaml @@ -3434,10 +3434,13 @@ components: description: >- True when a fetch reached its row cap, so older rows (and possibly pending approvals) may be missing from purchases and summary. May - be a false positive when exactly `limit` rows exist. + be a false positive when exactly `limit` or `executions_limit` rows exist. limit: type: integer - description: Per-source row cap in effect for this request. + description: Row cap on completed purchase history rows for this request. + executions_limit: + type: integer + description: Row cap on non-completed executions (fixed, ignores the limit parameter). HistorySummary: type: object diff --git a/internal/api/types.go b/internal/api/types.go index b43d54dd..0841f93c 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -924,10 +924,13 @@ type HistoryResponse struct { Summary HistorySummary `json:"summary"` // Truncated is true when a fetch reached its row cap, so older rows (and // possibly pending approvals) may be missing from Purchases and Summary. - // It can be a false positive when exactly Limit rows exist. + // It can be a false positive when exactly Limit (or ExecutionsLimit) rows exist. Truncated bool `json:"truncated"` - // Limit is the per-source row cap in effect for this request. + // Limit is the row cap on completed purchase_history rows for this request. Limit int `json:"limit"` + // ExecutionsLimit is the row cap on non-completed executions, which is + // fixed regardless of the request's limit parameter. + ExecutionsLimit int `json:"executions_limit"` } // HistorySummary provides aggregate statistics for purchase history. diff --git a/internal/config/store_postgres.go b/internal/config/store_postgres.go index 63c28490..e952067e 100644 --- a/internal/config/store_postgres.go +++ b/internal/config/store_postgres.go @@ -1360,6 +1360,10 @@ func (s *PostgresStore) CancelScheduledExecutionAtomic(ctx context.Context, tx p // handler to merge pending/failed/expired rows alongside completed purchases // without changing the narrower GetPendingExecutions contract the scheduler // depends on. +// +// Executions with status 'completed' and an empty error are excluded: they are +// already represented by their purchase_history rows, so returning them would +// only consume the cap (the History handler skips them anyway). func (s *PostgresStore) GetExecutionsByStatuses(ctx context.Context, statuses []string, limit int) ([]PurchaseExecution, error) { if len(statuses) == 0 { return nil, nil @@ -1381,6 +1385,7 @@ func (s *PostgresStore) GetExecutionsByStatuses(ctx context.Context, statuses [] idempotency_key, scheduled_execution_at FROM purchase_executions WHERE status = ANY($1) + AND NOT (status = 'completed' AND COALESCE(error, '') = '') ORDER BY scheduled_date DESC LIMIT $2 ` From 98574a578c9ac52d0924fabc6946027c44b9bbd8 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 6 Oct 2026 21:26:53 +0200 Subject: [PATCH 3/4] test(history): drop constant creator parameter from truncation helper --- .../api/handler_history_truncation_test.go | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/internal/api/handler_history_truncation_test.go b/internal/api/handler_history_truncation_test.go index 78e9677d..4fe6dfc6 100644 --- a/internal/api/handler_history_truncation_test.go +++ b/internal/api/handler_history_truncation_test.go @@ -22,14 +22,13 @@ func historyRows(n int) []config.PurchaseHistoryRecord { return rows } -func pendingExecutions(n int, creatorID *string) []config.PurchaseExecution { +func pendingExecutions(n int) []config.PurchaseExecution { execs := make([]config.PurchaseExecution, n) for i := range execs { execs[i] = config.PurchaseExecution{ - ExecutionID: fmt.Sprintf("e-%d", i), - Status: "pending", - ScheduledDate: time.Now(), - CreatedByUserID: creatorID, + ExecutionID: fmt.Sprintf("e-%d", i), + Status: "pending", + ScheduledDate: time.Now(), } } return execs @@ -70,12 +69,12 @@ func TestHandler_getHistory_TruncatedWhenCompletedAtCap(t *testing.T) { func TestHandler_getHistory_TruncatedWhenExecutionsAtCap(t *testing.T) { // The execution fetch is capped before the in-memory filters run, so a // full page means pending approvals may have been dropped. - body := runAdminHistory(t, nil, pendingExecutions(config.DefaultListLimit, nil), map[string]string{}) + body := runAdminHistory(t, nil, pendingExecutions(config.DefaultListLimit), map[string]string{}) assert.Equal(t, true, body["truncated"]) } func cleanCompletedExecutions(n int) []config.PurchaseExecution { - execs := pendingExecutions(n, nil) + execs := pendingExecutions(n) for i := range execs { execs[i].Status = "completed" } @@ -91,7 +90,7 @@ func TestHandler_getHistory_NotTruncatedByCleanCompletedExecutions(t *testing.T) } func TestHandler_getHistory_TruncatedByFailedExecutions(t *testing.T) { - execs := pendingExecutions(config.DefaultListLimit, nil) + execs := pendingExecutions(config.DefaultListLimit) for i := range execs { execs[i].Status = "failed" } @@ -109,7 +108,7 @@ func TestHandler_getHistory_AuditGapCompletedExecutionsCountTowardCap(t *testing } func TestHandler_getHistory_ExecutionsLimitIsFixed(t *testing.T) { - body := runAdminHistory(t, historyRows(2), pendingExecutions(config.DefaultListLimit, nil), map[string]string{"limit": "500"}) + body := runAdminHistory(t, historyRows(2), pendingExecutions(config.DefaultListLimit), map[string]string{"limit": "500"}) assert.EqualValues(t, 500, body["limit"]) assert.EqualValues(t, config.DefaultListLimit, body["executions_limit"]) assert.Equal(t, true, body["truncated"]) @@ -122,7 +121,7 @@ func TestHandler_getHistory_TruncatedHonorsCustomLimit(t *testing.T) { } func TestHandler_getHistory_NotTruncatedUnderLimit(t *testing.T) { - body := runAdminHistory(t, historyRows(config.DefaultListLimit-1), pendingExecutions(config.DefaultListLimit-1, nil), map[string]string{}) + body := runAdminHistory(t, historyRows(config.DefaultListLimit-1), pendingExecutions(config.DefaultListLimit-1), map[string]string{}) assert.Equal(t, false, body["truncated"]) assert.EqualValues(t, config.DefaultListLimit, body["limit"]) } @@ -134,7 +133,7 @@ func TestHandler_getHistory_ScopedUserTruncationLeaksNoCounts(t *testing.T) { ctx := context.Background() const scopedUserID = "scoped-user-id" own := scopedUserID - execs := pendingExecutions(config.DefaultListLimit, nil) + execs := pendingExecutions(config.DefaultListLimit) execs[0].CreatedByUserID = &own mockStore := new(MockConfigStore) From 8e0033e67c8acc46568fb6c4ea79ca69596907b9 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 6 Oct 2026 21:38:40 +0200 Subject: [PATCH 4/4] test(config): cover the clean-completed predicate in GetExecutionsByStatuses --- ...executions_by_statuses_integration_test.go | 56 +++++++++++++++++++ .../config/store_postgres_pgxmock_test.go | 19 +++++++ 2 files changed, 75 insertions(+) create mode 100644 internal/config/store_postgres_executions_by_statuses_integration_test.go diff --git a/internal/config/store_postgres_executions_by_statuses_integration_test.go b/internal/config/store_postgres_executions_by_statuses_integration_test.go new file mode 100644 index 00000000..9de546ba --- /dev/null +++ b/internal/config/store_postgres_executions_by_statuses_integration_test.go @@ -0,0 +1,56 @@ +//go:build integration +// +build integration + +package config + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestIntegration_GetExecutionsByStatuses_RowClasses runs the real query over +// each row class the History view cares about. A clean completed execution +// (status completed, empty error) must be excluded; a completed execution with +// an error (the audit-gap case) and every other requested status must be +// returned, newest first and bounded by the limit. +func TestIntegration_GetExecutionsByStatuses_RowClasses(t *testing.T) { + ctx := context.Background() + store := setupRampStepStore(ctx, t) + + base := time.Now().Add(-time.Hour) + save := func(offset int, status, execErr string) string { + exec := &PurchaseExecution{ + ExecutionID: uuid.New().String(), + IdempotencyKey: uuid.New().String(), + Status: status, + Error: execErr, + ScheduledDate: base.Add(time.Duration(offset) * time.Minute), + } + require.NoError(t, store.SavePurchaseExecution(ctx, exec)) + return exec.ExecutionID + } + + save(1, "completed", "") + auditGap := save(2, "completed", "history write failed") + failed := save(3, "failed", "boom") + pending := save(4, "pending", "") + + got, err := store.GetExecutionsByStatuses(ctx, []string{"pending", "completed", "failed"}, 100) + require.NoError(t, err) + ids := make([]string, len(got)) + for i := range got { + ids[i] = got[i].ExecutionID + } + assert.Equal(t, []string{pending, failed, auditGap}, ids, "clean completed excluded; newest first") + + limited, err := store.GetExecutionsByStatuses(ctx, []string{"pending", "completed", "failed"}, 2) + require.NoError(t, err) + require.Len(t, limited, 2) + assert.Equal(t, pending, limited[0].ExecutionID) + assert.Equal(t, failed, limited[1].ExecutionID) +} diff --git a/internal/config/store_postgres_pgxmock_test.go b/internal/config/store_postgres_pgxmock_test.go index a8098804..83eacf14 100644 --- a/internal/config/store_postgres_pgxmock_test.go +++ b/internal/config/store_postgres_pgxmock_test.go @@ -3062,3 +3062,22 @@ func TestPGXMock_CountExecutionsByPlanAndStatus_QueryError(t *testing.T) { assert.Contains(t, err.Error(), "failed to count executions by plan and status") assert.NoError(t, mock.ExpectationsWereMet()) } + +// TestPGXMock_GetExecutionsByStatuses_ExcludesCleanCompleted guards the SQL +// predicate that keeps clean completed executions (status completed, empty +// error) out of the result: they only consume the cap, because the History +// handler skips them. Dropping the predicate lets them push pending +// approvals past the cap again. +func TestPGXMock_GetExecutionsByStatuses_ExcludesCleanCompleted(t *testing.T) { + mock := newMock(t) + store := storeWith(mock) + + mock.ExpectQuery(`(?s)FROM purchase_executions\s+WHERE status = ANY\(\$1\)\s+AND NOT \(status = 'completed' AND COALESCE\(error, ''\) = ''\)\s+ORDER BY scheduled_date DESC\s+LIMIT \$2`). + WithArgs(pgxmock.AnyArg(), pgxmock.AnyArg()). + WillReturnRows(pgxmock.NewRows(stuckExecCols())) + + execs, err := store.GetExecutionsByStatuses(context.Background(), []string{"pending", "completed"}, 100) + require.NoError(t, err) + assert.Empty(t, execs) + assert.NoError(t, mock.ExpectationsWereMet()) +}