Skip to content
Open
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
6 changes: 6 additions & 0 deletions frontend/src/__tests__/allowed-accounts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,12 @@ jest.mock('../state', () => ({
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(),
}));

// ---------------------------------------------------------------------------
Expand Down
125 changes: 125 additions & 0 deletions frontend/src/__tests__/approval-queue-column-filters.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/**
* Approval Queue column-filter regression suite (issue #166).
*
* Covers the inline per-column filters wired onto the Approval Queue
* table headers. Filterable columns: Provider, Account, Service, Term,
* Payment, Created by (categorical), Count, Monthly Cost, Upfront Cost,
* Monthly Savings (numeric). Status is excluded by design — the queue
* scope is already pending|notified.
*
* Tested matrix:
* 1. Numeric expr filter narrows by predicate (monthly_cost >= N).
* 2. Categorical set filter narrows by membership (payment in set).
* 3. Multiple filters AND together across columns.
* 4. Invalid numeric expression is skipped (no exception, no narrowing).
* 5. NaN-as-missing contract: monthly_cost == null produces NaN, which
* fails every numeric predicate (not coincidentally matches "= 0").
* 6. Clearing returns a fresh clone of the input.
*/

import { applyApprovalQueueColumnFilters } from '../history';
import type { HistoryPurchase } from '../types';
import type { ApprovalQueueColumnFilters } from '../state';

jest.mock('../api', () => ({}));
jest.mock('../navigation', () => ({ switchTab: jest.fn() }));
jest.mock('../utils', () => ({
formatCurrency: jest.fn((v) => `$${v ?? 0}`),
formatDate: jest.fn((v) => v),
formatTerm: jest.fn((y) => `${y} Year${y === 1 ? '' : 's'}`),
escapeHtml: jest.fn((s) => s ?? ''),
}));
jest.mock('../state', () => ({
subscribeProvider: jest.fn().mockReturnValue(() => {}),
subscribeAccount: jest.fn().mockReturnValue(() => {}),
}));
jest.mock('../confirmDialog', () => ({ confirmDialog: jest.fn() }));
jest.mock('../approval-details', () => ({ buildApprovalDetailsBody: jest.fn() }));
jest.mock('../toast', () => ({ showToast: jest.fn() }));
jest.mock('../lib/skeleton', () => ({ showSkeletonRows: jest.fn(), teardownSkeleton: jest.fn() }));
jest.mock('../recommendations', () => ({ getAccountName: jest.fn((id: string) => id) }));

function mkRow(overrides: Partial<HistoryPurchase>): HistoryPurchase {
return {
purchase_id: 'p',
timestamp: '2024-01-01T00:00:00Z',
provider: 'aws',
service: 'ec2',
resource_type: 'reserved-instance',
region: 'us-east-1',
count: 1,
term: 1,
payment: 'all_upfront',
upfront_cost: 100,
monthly_cost: 50,
estimated_savings: 30,
account_id: 'acct-1',
created_by_user_email: 'alice@example.com',
status: 'pending',
...overrides,
};
}

const rows: HistoryPurchase[] = [
mkRow({ purchase_id: 'a', provider: 'aws', account_id: 'acct-1', payment: 'all_upfront', monthly_cost: 50, estimated_savings: 30, created_by_user_email: 'alice@example.com' }),
mkRow({ purchase_id: 'b', provider: 'aws', account_id: 'acct-2', payment: 'no_upfront', monthly_cost: 200, estimated_savings: 100, created_by_user_email: 'bob@example.com' }),
mkRow({ purchase_id: 'c', provider: 'azure', account_id: 'acct-2', payment: 'partial_upfront', monthly_cost: null as unknown as number | undefined, estimated_savings: 60, created_by_user_email: 'alice@example.com' }),
mkRow({ purchase_id: 'd', provider: 'gcp', account_id: 'acct-3', payment: 'all_upfront', monthly_cost: 500, estimated_savings: 250, created_by_user_email: 'carol@example.com' }),
];

describe('applyApprovalQueueColumnFilters', () => {
test('numeric expr: monthly_cost >= 200 narrows to expensive rows', () => {
const filters: ApprovalQueueColumnFilters = {
monthly_cost: { kind: 'expr', expr: '>=200' },
};
const out = applyApprovalQueueColumnFilters(rows, filters);
expect(out.map((r) => r.purchase_id)).toEqual(['b', 'd']);
});

test('categorical set: payment in {all_upfront} narrows to those rows', () => {
const filters: ApprovalQueueColumnFilters = {
payment: { kind: 'set', values: ['all_upfront'] },
};
const out = applyApprovalQueueColumnFilters(rows, filters);
expect(out.map((r) => r.purchase_id)).toEqual(['a', 'd']);
});

test('multiple filters AND together (provider=aws + created_by=alice)', () => {
const filters: ApprovalQueueColumnFilters = {
provider: { kind: 'set', values: ['aws'] },
created_by: { kind: 'set', values: ['alice@example.com'] },
};
const out = applyApprovalQueueColumnFilters(rows, filters);
expect(out.map((r) => r.purchase_id)).toEqual(['a']);
});

test('invalid numeric expression is skipped (filter is a no-op)', () => {
const filters: ApprovalQueueColumnFilters = {
savings: { kind: 'expr', expr: 'not-a-num' },
};
const out = applyApprovalQueueColumnFilters(rows, filters);
expect(out).toHaveLength(rows.length);
});

test('null monthly_cost fails every numeric predicate (NaN contract)', () => {
// Row c has monthly_cost: null. A "= 0" predicate must NOT match it,
// and a ">0" predicate must NOT match it either.
const eqZero: ApprovalQueueColumnFilters = {
monthly_cost: { kind: 'expr', expr: '0' },
};
expect(applyApprovalQueueColumnFilters(rows, eqZero).map((r) => r.purchase_id))
.not.toContain('c');

const gtZero: ApprovalQueueColumnFilters = {
monthly_cost: { kind: 'expr', expr: '>0' },
};
expect(applyApprovalQueueColumnFilters(rows, gtZero).map((r) => r.purchase_id))
.not.toContain('c');
});

test('clearing filters via empty record returns a fresh clone', () => {
const out = applyApprovalQueueColumnFilters(rows, {});
expect(out).toEqual(rows);
expect(out).not.toBe(rows);
});
});
6 changes: 6 additions & 0 deletions frontend/src/__tests__/history-approval-queue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,12 @@ jest.mock('../state', () => ({
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', () => ({
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/__tests__/history-approve-button.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,12 @@ jest.mock('../state', () => ({
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(),
}));

import * as api from '../api';
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/__tests__/history-cancel-button.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,12 @@ jest.mock('../state', () => ({
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(),
}));

import * as api from '../api';
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/__tests__/history-cancel-permissions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@ jest.mock('../state', () => ({
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(),
}));

// Mock permissions so we can inject arbitrary permission sets, including
Expand Down
117 changes: 117 additions & 0 deletions frontend/src/__tests__/history-column-filters.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/**
* Purchase History column-filter regression suite (issue #166).
*
* Covers the inline per-column filters wired onto the Purchase History
* table headers. The existing Status chip-row remains the canonical
* filter for status — these tests exercise the new column-filter slice
* (provider/service/type/region/term + count/upfront_cost/savings).
*
* Tested matrix:
* 1. Numeric expr filter narrows by predicate (savings > N).
* 2. Categorical set filter narrows by membership (provider in set).
* 3. Multiple filters AND together across columns.
* 4. Invalid numeric expression is skipped (no exception, no narrowing).
* 5. Clearing a filter via setPurchaseHistoryColumnFilter(col, null)
* restores the full slice.
* 6. Term column treats absent vs zero correctly — categorical-empty
* filtering.
*/

import { applyPurchaseHistoryColumnFilters } from '../history';
import type { HistoryPurchase } from '../types';
import type { PurchaseHistoryColumnFilters } from '../state';

// history.ts pulls in api/state/navigation transitively; the column-filter
// helper is pure (operates on the passed-in rows + filter record), but the
// module import path still resolves those — stub them out so the test runs
// without an apiBase / DOM context.
jest.mock('../api', () => ({}));
jest.mock('../navigation', () => ({ switchTab: jest.fn() }));
jest.mock('../utils', () => ({
formatCurrency: jest.fn((v) => `$${v ?? 0}`),
formatDate: jest.fn((v) => v),
formatTerm: jest.fn((y) => `${y} Year${y === 1 ? '' : 's'}`),
escapeHtml: jest.fn((s) => s ?? ''),
}));
jest.mock('../state', () => ({
subscribeProvider: jest.fn().mockReturnValue(() => {}),
subscribeAccount: jest.fn().mockReturnValue(() => {}),
}));
jest.mock('../confirmDialog', () => ({ confirmDialog: jest.fn() }));
jest.mock('../approval-details', () => ({ buildApprovalDetailsBody: jest.fn() }));
jest.mock('../toast', () => ({ showToast: jest.fn() }));
jest.mock('../lib/skeleton', () => ({ showSkeletonRows: jest.fn(), teardownSkeleton: jest.fn() }));
jest.mock('../recommendations', () => ({ getAccountName: jest.fn((id: string) => id) }));

function mkRow(overrides: Partial<HistoryPurchase>): HistoryPurchase {
return {
purchase_id: 'p',
timestamp: '2024-01-01T00:00:00Z',
provider: 'aws',
service: 'ec2',
resource_type: 'reserved-instance',
region: 'us-east-1',
count: 1,
term: 1,
upfront_cost: 100,
estimated_savings: 50,
...overrides,
};
}

const rows: HistoryPurchase[] = [
mkRow({ purchase_id: 'a', provider: 'aws', service: 'ec2', region: 'us-east-1', count: 1, term: 1, upfront_cost: 100, estimated_savings: 50 }),
mkRow({ purchase_id: 'b', provider: 'aws', service: 'rds', region: 'us-west-2', count: 3, term: 3, upfront_cost: 500, estimated_savings: 200 }),
mkRow({ purchase_id: 'c', provider: 'azure', service: 'ec2', region: 'eu-west-1', count: 5, term: 1, upfront_cost: 1000, estimated_savings: 400 }),
mkRow({ purchase_id: 'd', provider: 'gcp', service: 'ec2', region: 'us-east-1', count: 2, term: 1, upfront_cost: 250, estimated_savings: 80 }),
];

describe('applyPurchaseHistoryColumnFilters', () => {
test('numeric expr: savings > 100 narrows to high-saving rows', () => {
const filters: PurchaseHistoryColumnFilters = {
savings: { kind: 'expr', expr: '>100' },
};
const out = applyPurchaseHistoryColumnFilters(rows, filters);
expect(out.map((r) => r.purchase_id)).toEqual(['b', 'c']);
});

test('categorical set: provider in {aws, gcp} excludes azure', () => {
const filters: PurchaseHistoryColumnFilters = {
provider: { kind: 'set', values: ['aws', 'gcp'] },
};
const out = applyPurchaseHistoryColumnFilters(rows, filters);
expect(out.map((r) => r.purchase_id)).toEqual(['a', 'b', 'd']);
});

test('multiple filters AND together (provider=aws + savings >= 100)', () => {
const filters: PurchaseHistoryColumnFilters = {
provider: { kind: 'set', values: ['aws'] },
savings: { kind: 'expr', expr: '>=100' },
};
const out = applyPurchaseHistoryColumnFilters(rows, filters);
expect(out.map((r) => r.purchase_id)).toEqual(['b']);
});

test('invalid numeric expression is skipped (filter is a no-op)', () => {
const filters: PurchaseHistoryColumnFilters = {
savings: { kind: 'expr', expr: '>>nope' },
};
const out = applyPurchaseHistoryColumnFilters(rows, filters);
// Parse failure → filter ignored; full slice passes.
expect(out).toHaveLength(rows.length);
});

test('clearing filters via empty record returns a fresh clone', () => {
const out = applyPurchaseHistoryColumnFilters(rows, {});
expect(out).toEqual(rows);
expect(out).not.toBe(rows);
});

test('term filter uses categorical-set semantics with stringified values', () => {
const filters: PurchaseHistoryColumnFilters = {
term: { kind: 'set', values: ['3'] },
};
const out = applyPurchaseHistoryColumnFilters(rows, filters);
expect(out.map((r) => r.purchase_id)).toEqual(['b']);
});
});
6 changes: 6 additions & 0 deletions frontend/src/__tests__/history-retry-button.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,12 @@ jest.mock('../state', () => ({
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(),
}));

import * as api from '../api';
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/__tests__/history.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,14 @@ jest.mock('../state', () => ({
getAmortizeUpfront: jest.fn().mockReturnValue(false),
setAmortizeUpfront: jest.fn(),
subscribeAmortizeUpfront: jest.fn().mockReturnValue(() => {}),
// History per-column filter accessors (issue #166): tests only exercise
// empty filter state, so each getter returns {} and each setter is a no-op.
getPurchaseHistoryColumnFilters: jest.fn().mockReturnValue({}),
setPurchaseHistoryColumnFilter: jest.fn(),
clearAllPurchaseHistoryColumnFilters: jest.fn(),
getApprovalQueueColumnFilters: jest.fn().mockReturnValue({}),
setApprovalQueueColumnFilter: jest.fn(),
clearAllApprovalQueueColumnFilters: jest.fn(),
}));

import * as api from '../api';
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/__tests__/xss-provider-class.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ jest.mock('../state', () => ({
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(),
}));

import * as api from '../api';
Expand Down
Loading
Loading