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
112 changes: 112 additions & 0 deletions frontend/src/__tests__/history-truncation-notice.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/**
* 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, executions_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, 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);
}
});
});
21 changes: 21 additions & 0 deletions frontend/src/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,13 +270,15 @@ export async function viewPlanHistory(planId: string): Promise<void> {
]);
if (requestEpoch !== historyMutationEpoch) return;
renderHistorySummary(data.summary ?? null);
renderTruncationNotices(data);
const purchases = data.purchases || [];
renderApprovalQueue(purchases);
renderHistoryList(purchases);
snapDateInputsToPurchases(purchases);
} 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) {
Expand Down Expand Up @@ -362,12 +364,14 @@ export async function loadHistory(): Promise<void> {
]);
if (requestEpoch !== historyMutationEpoch) return;
renderHistorySummary(data.summary ?? null);
renderTruncationNotices(data);
const purchases = data.purchases || [];
renderApprovalQueue(purchases);
renderHistoryList(purchases);
} 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) {
Expand All @@ -382,6 +386,23 @@ export async function loadHistory(): Promise<void> {
}
}

/**
* 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
? `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);
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;
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ <h2>Planned Purchases</h2>
<section id="purchases-approval-queue-section" class="card page-hero">
<h2>Approval queue</h2>
<p class="help-text page-hero-description">Purchases awaiting approval. Review and approve or cancel each one. The same actions are available from the Purchase History table below.</p>
<p id="purchases-truncation-notice" class="warning-message" role="status" hidden></p>
<section id="purchases-approval-queue"></section>
</section>

Expand Down Expand Up @@ -249,6 +250,7 @@ <h2>Purchase History</h2>
<button id="load-history-btn">Load History</button>
</div>
</section>
<p id="history-truncation-notice" class="warning-message" role="status" hidden></p>
<section id="history-summary"></section>
<section id="history-list"></section>
</section>
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,12 @@ 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;
/** 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 {
Expand Down
27 changes: 20 additions & 7 deletions internal/api/handler_history.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -79,9 +79,15 @@ 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,
Summary: summarizePurchaseHistory(all),
Purchases: all,
Truncated: len(completed) >= filters.Limit || executionsCapped,
Limit: filters.Limit,
ExecutionsLimit: config.DefaultListLimit,
}, nil
}

Expand Down Expand Up @@ -145,19 +151,25 @@ 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
}
if len(executions) == 0 {
return nil, nil
return nil, nil, false
}
approver := h.resolvePendingApproverEmail(ctx)
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
Expand All @@ -168,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
Expand All @@ -184,7 +197,7 @@ func (h *Handler) fetchExecutionsAsHistory(ctx context.Context, filters historyF
}
out = append(out, executionToHistoryRow(exec, approver, createdByEmail))
}
return out, staleExecs
return out, staleExecs, surfaced >= config.DefaultListLimit
}

// isStaleExecution reports whether the execution is a pending/notified
Expand Down
Loading
Loading