|
| 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 | +}); |
0 commit comments