Skip to content

Commit 2f2a298

Browse files
committed
test(frontend/history): column-filter regression suites (refs #166)
Adds two test files exercising the new History column-filter wiring: * history-column-filters.test.ts — Purchase History table: numeric expr, categorical set, stacked AND, invalid expr (no-op), clear, and the term-as-stringified-categorical case. * approval-queue-column-filters.test.ts — Approval Queue table: numeric expr (monthly_cost >= N), categorical set (payment in {…}), stacked AND across provider+created_by, invalid expr (no-op), the NaN-as-missing contract for null monthly_cost (so "= 0" and "> 0" don't coincidentally match unreported rows), and clear. Both suites mock the heavy module transitive deps (api / navigation / utils / state / confirmDialog / approval-details / toast / skeleton / recommendations) so the pure column-filter helpers can be exercised without standing up a DOM.
1 parent 548cfc8 commit 2f2a298

2 files changed

Lines changed: 242 additions & 0 deletions

File tree

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
/**
2+
* Approval Queue column-filter regression suite (issue #166).
3+
*
4+
* Covers the inline per-column filters wired onto the Approval Queue
5+
* table headers. Filterable columns: Provider, Account, Service, Term,
6+
* Payment, Created by (categorical), Count, Monthly Cost, Upfront Cost,
7+
* Monthly Savings (numeric). Status is excluded by design — the queue
8+
* scope is already pending|notified.
9+
*
10+
* Tested matrix:
11+
* 1. Numeric expr filter narrows by predicate (monthly_cost >= N).
12+
* 2. Categorical set filter narrows by membership (payment in set).
13+
* 3. Multiple filters AND together across columns.
14+
* 4. Invalid numeric expression is skipped (no exception, no narrowing).
15+
* 5. NaN-as-missing contract: monthly_cost == null produces NaN, which
16+
* fails every numeric predicate (not coincidentally matches "= 0").
17+
* 6. Clearing returns a fresh clone of the input.
18+
*/
19+
20+
import { applyApprovalQueueColumnFilters } from '../history';
21+
import type { HistoryPurchase } from '../types';
22+
import type { ApprovalQueueColumnFilters } from '../state';
23+
24+
jest.mock('../api', () => ({}));
25+
jest.mock('../navigation', () => ({ switchTab: jest.fn() }));
26+
jest.mock('../utils', () => ({
27+
formatCurrency: jest.fn((v) => `$${v ?? 0}`),
28+
formatDate: jest.fn((v) => v),
29+
formatTerm: jest.fn((y) => `${y} Year${y === 1 ? '' : 's'}`),
30+
escapeHtml: jest.fn((s) => s ?? ''),
31+
}));
32+
jest.mock('../state', () => ({
33+
subscribeProvider: jest.fn().mockReturnValue(() => {}),
34+
subscribeAccount: jest.fn().mockReturnValue(() => {}),
35+
}));
36+
jest.mock('../confirmDialog', () => ({ confirmDialog: jest.fn() }));
37+
jest.mock('../approval-details', () => ({ buildApprovalDetailsBody: jest.fn() }));
38+
jest.mock('../toast', () => ({ showToast: jest.fn() }));
39+
jest.mock('../lib/skeleton', () => ({ showSkeletonRows: jest.fn(), teardownSkeleton: jest.fn() }));
40+
jest.mock('../recommendations', () => ({ getAccountName: jest.fn((id: string) => id) }));
41+
42+
function mkRow(overrides: Partial<HistoryPurchase>): HistoryPurchase {
43+
return {
44+
purchase_id: 'p',
45+
timestamp: '2024-01-01T00:00:00Z',
46+
provider: 'aws',
47+
service: 'ec2',
48+
resource_type: 'reserved-instance',
49+
region: 'us-east-1',
50+
count: 1,
51+
term: 1,
52+
payment: 'all_upfront',
53+
upfront_cost: 100,
54+
monthly_cost: 50,
55+
estimated_savings: 30,
56+
account_id: 'acct-1',
57+
created_by_user_email: 'alice@example.com',
58+
status: 'pending',
59+
...overrides,
60+
};
61+
}
62+
63+
const rows: HistoryPurchase[] = [
64+
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' }),
65+
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' }),
66+
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' }),
67+
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' }),
68+
];
69+
70+
describe('applyApprovalQueueColumnFilters', () => {
71+
test('numeric expr: monthly_cost >= 200 narrows to expensive rows', () => {
72+
const filters: ApprovalQueueColumnFilters = {
73+
monthly_cost: { kind: 'expr', expr: '>=200' },
74+
};
75+
const out = applyApprovalQueueColumnFilters(rows, filters);
76+
expect(out.map((r) => r.purchase_id)).toEqual(['b', 'd']);
77+
});
78+
79+
test('categorical set: payment in {all_upfront} narrows to those rows', () => {
80+
const filters: ApprovalQueueColumnFilters = {
81+
payment: { kind: 'set', values: ['all_upfront'] },
82+
};
83+
const out = applyApprovalQueueColumnFilters(rows, filters);
84+
expect(out.map((r) => r.purchase_id)).toEqual(['a', 'd']);
85+
});
86+
87+
test('multiple filters AND together (provider=aws + created_by=alice)', () => {
88+
const filters: ApprovalQueueColumnFilters = {
89+
provider: { kind: 'set', values: ['aws'] },
90+
created_by: { kind: 'set', values: ['alice@example.com'] },
91+
};
92+
const out = applyApprovalQueueColumnFilters(rows, filters);
93+
expect(out.map((r) => r.purchase_id)).toEqual(['a']);
94+
});
95+
96+
test('invalid numeric expression is skipped (filter is a no-op)', () => {
97+
const filters: ApprovalQueueColumnFilters = {
98+
savings: { kind: 'expr', expr: 'not-a-num' },
99+
};
100+
const out = applyApprovalQueueColumnFilters(rows, filters);
101+
expect(out).toHaveLength(rows.length);
102+
});
103+
104+
test('null monthly_cost fails every numeric predicate (NaN contract)', () => {
105+
// Row c has monthly_cost: null. A "= 0" predicate must NOT match it,
106+
// and a ">0" predicate must NOT match it either.
107+
const eqZero: ApprovalQueueColumnFilters = {
108+
monthly_cost: { kind: 'expr', expr: '0' },
109+
};
110+
expect(applyApprovalQueueColumnFilters(rows, eqZero).map((r) => r.purchase_id))
111+
.not.toContain('c');
112+
113+
const gtZero: ApprovalQueueColumnFilters = {
114+
monthly_cost: { kind: 'expr', expr: '>0' },
115+
};
116+
expect(applyApprovalQueueColumnFilters(rows, gtZero).map((r) => r.purchase_id))
117+
.not.toContain('c');
118+
});
119+
120+
test('clearing filters via empty record returns a fresh clone', () => {
121+
const out = applyApprovalQueueColumnFilters(rows, {});
122+
expect(out).toEqual(rows);
123+
expect(out).not.toBe(rows);
124+
});
125+
});
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/**
2+
* Purchase History column-filter regression suite (issue #166).
3+
*
4+
* Covers the inline per-column filters wired onto the Purchase History
5+
* table headers. The existing Status chip-row remains the canonical
6+
* filter for status — these tests exercise the new column-filter slice
7+
* (provider/service/type/region/term + count/upfront_cost/savings).
8+
*
9+
* Tested matrix:
10+
* 1. Numeric expr filter narrows by predicate (savings > N).
11+
* 2. Categorical set filter narrows by membership (provider in set).
12+
* 3. Multiple filters AND together across columns.
13+
* 4. Invalid numeric expression is skipped (no exception, no narrowing).
14+
* 5. Clearing a filter via setPurchaseHistoryColumnFilter(col, null)
15+
* restores the full slice.
16+
* 6. Term column treats absent vs zero correctly — categorical-empty
17+
* filtering.
18+
*/
19+
20+
import { applyPurchaseHistoryColumnFilters } from '../history';
21+
import type { HistoryPurchase } from '../types';
22+
import type { PurchaseHistoryColumnFilters } from '../state';
23+
24+
// history.ts pulls in api/state/navigation transitively; the column-filter
25+
// helper is pure (operates on the passed-in rows + filter record), but the
26+
// module import path still resolves those — stub them out so the test runs
27+
// without an apiBase / DOM context.
28+
jest.mock('../api', () => ({}));
29+
jest.mock('../navigation', () => ({ switchTab: jest.fn() }));
30+
jest.mock('../utils', () => ({
31+
formatCurrency: jest.fn((v) => `$${v ?? 0}`),
32+
formatDate: jest.fn((v) => v),
33+
formatTerm: jest.fn((y) => `${y} Year${y === 1 ? '' : 's'}`),
34+
escapeHtml: jest.fn((s) => s ?? ''),
35+
}));
36+
jest.mock('../state', () => ({
37+
subscribeProvider: jest.fn().mockReturnValue(() => {}),
38+
subscribeAccount: jest.fn().mockReturnValue(() => {}),
39+
}));
40+
jest.mock('../confirmDialog', () => ({ confirmDialog: jest.fn() }));
41+
jest.mock('../approval-details', () => ({ buildApprovalDetailsBody: jest.fn() }));
42+
jest.mock('../toast', () => ({ showToast: jest.fn() }));
43+
jest.mock('../lib/skeleton', () => ({ showSkeletonRows: jest.fn(), teardownSkeleton: jest.fn() }));
44+
jest.mock('../recommendations', () => ({ getAccountName: jest.fn((id: string) => id) }));
45+
46+
function mkRow(overrides: Partial<HistoryPurchase>): HistoryPurchase {
47+
return {
48+
purchase_id: 'p',
49+
timestamp: '2024-01-01T00:00:00Z',
50+
provider: 'aws',
51+
service: 'ec2',
52+
resource_type: 'reserved-instance',
53+
region: 'us-east-1',
54+
count: 1,
55+
term: 1,
56+
upfront_cost: 100,
57+
estimated_savings: 50,
58+
...overrides,
59+
};
60+
}
61+
62+
const rows: HistoryPurchase[] = [
63+
mkRow({ purchase_id: 'a', provider: 'aws', service: 'ec2', region: 'us-east-1', count: 1, term: 1, upfront_cost: 100, estimated_savings: 50 }),
64+
mkRow({ purchase_id: 'b', provider: 'aws', service: 'rds', region: 'us-west-2', count: 3, term: 3, upfront_cost: 500, estimated_savings: 200 }),
65+
mkRow({ purchase_id: 'c', provider: 'azure', service: 'ec2', region: 'eu-west-1', count: 5, term: 1, upfront_cost: 1000, estimated_savings: 400 }),
66+
mkRow({ purchase_id: 'd', provider: 'gcp', service: 'ec2', region: 'us-east-1', count: 2, term: 1, upfront_cost: 250, estimated_savings: 80 }),
67+
];
68+
69+
describe('applyPurchaseHistoryColumnFilters', () => {
70+
test('numeric expr: savings > 100 narrows to high-saving rows', () => {
71+
const filters: PurchaseHistoryColumnFilters = {
72+
savings: { kind: 'expr', expr: '>100' },
73+
};
74+
const out = applyPurchaseHistoryColumnFilters(rows, filters);
75+
expect(out.map((r) => r.purchase_id)).toEqual(['b', 'c']);
76+
});
77+
78+
test('categorical set: provider in {aws, gcp} excludes azure', () => {
79+
const filters: PurchaseHistoryColumnFilters = {
80+
provider: { kind: 'set', values: ['aws', 'gcp'] },
81+
};
82+
const out = applyPurchaseHistoryColumnFilters(rows, filters);
83+
expect(out.map((r) => r.purchase_id)).toEqual(['a', 'b', 'd']);
84+
});
85+
86+
test('multiple filters AND together (provider=aws + savings >= 100)', () => {
87+
const filters: PurchaseHistoryColumnFilters = {
88+
provider: { kind: 'set', values: ['aws'] },
89+
savings: { kind: 'expr', expr: '>=100' },
90+
};
91+
const out = applyPurchaseHistoryColumnFilters(rows, filters);
92+
expect(out.map((r) => r.purchase_id)).toEqual(['b']);
93+
});
94+
95+
test('invalid numeric expression is skipped (filter is a no-op)', () => {
96+
const filters: PurchaseHistoryColumnFilters = {
97+
savings: { kind: 'expr', expr: '>>nope' },
98+
};
99+
const out = applyPurchaseHistoryColumnFilters(rows, filters);
100+
// Parse failure → filter ignored; full slice passes.
101+
expect(out).toHaveLength(rows.length);
102+
});
103+
104+
test('clearing filters via empty record returns a fresh clone', () => {
105+
const out = applyPurchaseHistoryColumnFilters(rows, {});
106+
expect(out).toEqual(rows);
107+
expect(out).not.toBe(rows);
108+
});
109+
110+
test('term filter uses categorical-set semantics with stringified values', () => {
111+
const filters: PurchaseHistoryColumnFilters = {
112+
term: { kind: 'set', values: ['3'] },
113+
};
114+
const out = applyPurchaseHistoryColumnFilters(rows, filters);
115+
expect(out.map((r) => r.purchase_id)).toEqual(['b']);
116+
});
117+
});

0 commit comments

Comments
 (0)