Skip to content

Commit 916c7ec

Browse files
chore: merge latest upstream routes-b updates
2 parents 553c9e2 + 5bdbccc commit 916c7ec

20 files changed

Lines changed: 1564 additions & 154 deletions

File tree

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/**
2+
* Tests for buildActionFilter — Issue #621
3+
*/
4+
5+
import { describe, it, expect } from 'vitest'
6+
import { buildActionFilter } from '../audit-action-filter'
7+
8+
function params(record: Record<string, string>): URLSearchParams {
9+
return new URLSearchParams(record)
10+
}
11+
12+
describe('buildActionFilter (#621)', () => {
13+
it('returns empty clause when both filters are missing', () => {
14+
const result = buildActionFilter(params({}))
15+
expect(result.ok).toBe(true)
16+
if (result.ok) expect(result.clause).toEqual({})
17+
})
18+
19+
it('builds equals filter for ?action', () => {
20+
const result = buildActionFilter(params({ action: 'refund.created' }))
21+
expect(result.ok).toBe(true)
22+
if (result.ok) {
23+
expect(result.clause).toEqual({ eventType: { equals: 'refund.created' } })
24+
}
25+
})
26+
27+
it('builds startsWith filter for ?actionPrefix', () => {
28+
const result = buildActionFilter(params({ actionPrefix: 'webhook.' }))
29+
expect(result.ok).toBe(true)
30+
if (result.ok) {
31+
expect(result.clause).toEqual({ eventType: { startsWith: 'webhook.' } })
32+
}
33+
})
34+
35+
it('rejects actionPrefix shorter than 2 chars', () => {
36+
const result = buildActionFilter(params({ actionPrefix: 'a' }))
37+
expect(result.ok).toBe(false)
38+
if (!result.ok) expect(result.error).toMatch(/2 and 64/)
39+
})
40+
41+
it('rejects actionPrefix longer than 64 chars', () => {
42+
const result = buildActionFilter(params({ actionPrefix: 'x'.repeat(65) }))
43+
expect(result.ok).toBe(false)
44+
})
45+
46+
it('action takes precedence when both are present', () => {
47+
const result = buildActionFilter(
48+
params({ action: 'refund.created', actionPrefix: 'webhook.' }),
49+
)
50+
expect(result.ok).toBe(true)
51+
if (result.ok) {
52+
expect(result.clause).toEqual({ eventType: { equals: 'refund.created' } })
53+
}
54+
})
55+
56+
it('trims whitespace from inputs', () => {
57+
const result = buildActionFilter(params({ action: ' refund.created ' }))
58+
expect(result.ok).toBe(true)
59+
if (result.ok) {
60+
expect(result.clause).toEqual({ eventType: { equals: 'refund.created' } })
61+
}
62+
})
63+
64+
it('treats empty string action as missing', () => {
65+
const result = buildActionFilter(params({ action: ' ' }))
66+
expect(result.ok).toBe(true)
67+
if (result.ok) expect(result.clause).toEqual({})
68+
})
69+
})
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/**
2+
* Tests for computeLateFee — Issue #599
3+
*/
4+
5+
import { describe, it, expect } from 'vitest'
6+
import { computeLateFee, DEFAULT_LATE_FEE_POLICY } from '../late-fee'
7+
8+
const baseInvoice = {
9+
amount: 1000,
10+
currency: 'USD',
11+
dueDate: new Date('2026-01-01'),
12+
}
13+
14+
describe('computeLateFee (#599)', () => {
15+
it('returns zero when invoice is not overdue', () => {
16+
const result = computeLateFee(baseInvoice, new Date('2025-12-31'))
17+
expect(result.amount).toBe(0)
18+
expect(result.applied).toBe(false)
19+
expect(result.daysLate).toBe(0)
20+
})
21+
22+
it('returns zero when overdue but less than one period', () => {
23+
const result = computeLateFee(baseInvoice, new Date('2026-01-15')) // 14 days late
24+
expect(result.amount).toBe(0)
25+
expect(result.applied).toBe(false)
26+
expect(result.daysLate).toBe(14)
27+
})
28+
29+
it('charges 1.5% for one full period (30 days)', () => {
30+
const result = computeLateFee(baseInvoice, new Date('2026-01-31')) // 30 days late
31+
// 1000 * 0.015 * 1 = 15
32+
expect(result.amount).toBe(15)
33+
expect(result.applied).toBe(true)
34+
expect(result.daysLate).toBe(30)
35+
expect(result.currency).toBe('USD')
36+
})
37+
38+
it('scales linearly across multiple periods', () => {
39+
const result = computeLateFee(baseInvoice, new Date('2026-04-01')) // 90 days = 3 periods
40+
// 1000 * 0.015 * 3 = 45
41+
expect(result.amount).toBe(45)
42+
expect(result.applied).toBe(true)
43+
})
44+
45+
it('caps at 10% of principal', () => {
46+
// 8 periods (240 days) → would be 12% → cap at 10%
47+
const result = computeLateFee(baseInvoice, new Date('2026-08-29'))
48+
expect(result.amount).toBe(100) // 10% of 1000
49+
expect(result.applied).toBe(true)
50+
})
51+
52+
it('returns zero for invoice with no due date', () => {
53+
const result = computeLateFee(
54+
{ amount: 1000, currency: 'USD', dueDate: null },
55+
new Date(),
56+
)
57+
expect(result.amount).toBe(0)
58+
expect(result.applied).toBe(false)
59+
})
60+
61+
it('preserves currency', () => {
62+
const result = computeLateFee(
63+
{ ...baseInvoice, currency: 'EUR' },
64+
new Date('2026-01-31'),
65+
)
66+
expect(result.currency).toBe('EUR')
67+
})
68+
69+
it('accepts ISO string dates', () => {
70+
const result = computeLateFee(
71+
{ ...baseInvoice, dueDate: '2026-01-01' },
72+
'2026-01-31',
73+
)
74+
expect(result.amount).toBe(15)
75+
})
76+
77+
it('uses default policy when none supplied', () => {
78+
const result = computeLateFee(baseInvoice, new Date('2026-01-31'))
79+
expect(result.amount).toBe(1000 * DEFAULT_LATE_FEE_POLICY.ratePerPeriod * 1)
80+
})
81+
82+
it('respects custom policy', () => {
83+
const result = computeLateFee(
84+
baseInvoice,
85+
new Date('2026-01-31'),
86+
{ ratePerPeriod: 0.05, periodDays: 30, capFraction: 0.5 },
87+
)
88+
expect(result.amount).toBe(50) // 1000 * 0.05 * 1
89+
})
90+
})
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/**
2+
* Tests for validateTagName — Issue #610
3+
*/
4+
5+
import { describe, it, expect } from 'vitest'
6+
import { validateTagName } from '../tag-validation'
7+
8+
describe('validateTagName (#610)', () => {
9+
it('accepts a valid name', () => {
10+
const result = validateTagName('Important')
11+
expect(result.ok).toBe(true)
12+
if (result.ok) expect(result.value).toBe('Important')
13+
})
14+
15+
it('trims whitespace', () => {
16+
const result = validateTagName(' spaced ')
17+
expect(result.ok).toBe(true)
18+
if (result.ok) expect(result.value).toBe('spaced')
19+
})
20+
21+
it('rejects non-string input', () => {
22+
expect(validateTagName(null).ok).toBe(false)
23+
expect(validateTagName(123).ok).toBe(false)
24+
expect(validateTagName(undefined).ok).toBe(false)
25+
})
26+
27+
it('rejects empty string', () => {
28+
expect(validateTagName('').ok).toBe(false)
29+
expect(validateTagName(' ').ok).toBe(false)
30+
})
31+
32+
it('rejects names longer than 32 chars', () => {
33+
expect(validateTagName('x'.repeat(33)).ok).toBe(false)
34+
})
35+
36+
it('accepts names exactly 32 chars', () => {
37+
expect(validateTagName('x'.repeat(32)).ok).toBe(true)
38+
})
39+
40+
it('rejects control characters', () => {
41+
expect(validateTagName('bad\x00name').ok).toBe(false)
42+
expect(validateTagName('bad\x07name').ok).toBe(false)
43+
})
44+
})
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
/**
2+
* Tests for webhook backoff config — Issue #607
3+
*/
4+
5+
import { describe, it, expect } from 'vitest'
6+
import {
7+
validateBackoff,
8+
computeBackoffDelay,
9+
DEFAULT_BACKOFF,
10+
} from '../webhook-backoff'
11+
12+
describe('validateBackoff (#607)', () => {
13+
it('returns defaults when input is null', () => {
14+
const result = validateBackoff(null)
15+
expect(result.ok).toBe(true)
16+
if (result.ok) expect(result.value).toEqual(DEFAULT_BACKOFF)
17+
})
18+
19+
it('merges partial config with defaults', () => {
20+
const result = validateBackoff({ initialMs: 500 })
21+
expect(result.ok).toBe(true)
22+
if (result.ok) {
23+
expect(result.value.initialMs).toBe(500)
24+
expect(result.value.maxMs).toBe(DEFAULT_BACKOFF.maxMs)
25+
}
26+
})
27+
28+
it('rejects initialMs below 50', () => {
29+
const result = validateBackoff({ initialMs: 10 })
30+
expect(result.ok).toBe(false)
31+
})
32+
33+
it('rejects initialMs above 5000', () => {
34+
const result = validateBackoff({ initialMs: 6000 })
35+
expect(result.ok).toBe(false)
36+
})
37+
38+
it('rejects maxMs below initialMs', () => {
39+
const result = validateBackoff({ initialMs: 1000, maxMs: 500 })
40+
expect(result.ok).toBe(false)
41+
})
42+
43+
it('rejects maxMs above 60000', () => {
44+
const result = validateBackoff({ maxMs: 70_000 })
45+
expect(result.ok).toBe(false)
46+
})
47+
48+
it('rejects multiplier below 1', () => {
49+
const result = validateBackoff({ multiplier: 0.5 })
50+
expect(result.ok).toBe(false)
51+
})
52+
53+
it('rejects multiplier above 5', () => {
54+
const result = validateBackoff({ multiplier: 6 })
55+
expect(result.ok).toBe(false)
56+
})
57+
58+
it('rejects jitter outside [0, 1]', () => {
59+
expect(validateBackoff({ jitter: -0.1 }).ok).toBe(false)
60+
expect(validateBackoff({ jitter: 1.1 }).ok).toBe(false)
61+
})
62+
63+
it('accepts boundary values', () => {
64+
const result = validateBackoff({
65+
initialMs: 50,
66+
maxMs: 60_000,
67+
multiplier: 5,
68+
jitter: 0,
69+
})
70+
expect(result.ok).toBe(true)
71+
})
72+
})
73+
74+
describe('computeBackoffDelay (#607)', () => {
75+
it('returns initialMs for attempt 0 with zero jitter', () => {
76+
const delay = computeBackoffDelay(
77+
0,
78+
{ ...DEFAULT_BACKOFF, jitter: 0 },
79+
() => 0.5,
80+
)
81+
expect(delay).toBe(DEFAULT_BACKOFF.initialMs)
82+
})
83+
84+
it('multiplies on each successive attempt', () => {
85+
const cfg = { initialMs: 100, maxMs: 10_000, multiplier: 2, jitter: 0 }
86+
expect(computeBackoffDelay(0, cfg, () => 0.5)).toBe(100)
87+
expect(computeBackoffDelay(1, cfg, () => 0.5)).toBe(200)
88+
expect(computeBackoffDelay(2, cfg, () => 0.5)).toBe(400)
89+
expect(computeBackoffDelay(3, cfg, () => 0.5)).toBe(800)
90+
})
91+
92+
it('caps at maxMs', () => {
93+
const cfg = { initialMs: 100, maxMs: 500, multiplier: 2, jitter: 0 }
94+
expect(computeBackoffDelay(10, cfg, () => 0.5)).toBe(500)
95+
})
96+
97+
it('respects jitter bounds', () => {
98+
const cfg = { initialMs: 1000, maxMs: 10_000, multiplier: 1, jitter: 0.5 }
99+
// With random()=0 → variance = 1000*0.5*-0.5 = -250 → 750
100+
// With random()=1 → variance = 1000*0.5*0.5 = 250 → 1250
101+
expect(computeBackoffDelay(0, cfg, () => 0)).toBe(750)
102+
expect(computeBackoffDelay(0, cfg, () => 1)).toBe(1250)
103+
})
104+
105+
it('never returns negative delays', () => {
106+
const cfg = { initialMs: 100, maxMs: 10_000, multiplier: 1, jitter: 1 }
107+
expect(computeBackoffDelay(0, cfg, () => -10)).toBeGreaterThanOrEqual(0)
108+
})
109+
})
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/**
2+
* audit-action-filter.ts — Issue #621
3+
*
4+
* Validates and builds the Prisma `where` clause for the audit-log
5+
* `?action=...` (exact match) and `?actionPrefix=...` (prefix match)
6+
* query parameters. Both compose with existing filters via AND.
7+
*/
8+
9+
const PREFIX_MIN = 2;
10+
const PREFIX_MAX = 64;
11+
12+
export type ActionFilterResult =
13+
| { ok: true; clause: { eventType?: { equals?: string; startsWith?: string } } }
14+
| { ok: false; error: string };
15+
16+
/**
17+
* Build a Prisma `eventType` filter clause from URL search params.
18+
*
19+
* - When both `action` and `actionPrefix` are missing → returns `{}` (no filter).
20+
* - When `action` is present → `{ eventType: { equals: action } }`.
21+
* - When `actionPrefix` is present → validates length [2, 64]; returns
22+
* `{ eventType: { startsWith: actionPrefix } }`.
23+
* - When `actionPrefix` is invalid → returns `{ ok: false, error }`.
24+
*
25+
* If both are provided, `action` (exact) takes precedence — the more
26+
* specific filter wins.
27+
*/
28+
export function buildActionFilter(
29+
searchParams: URLSearchParams,
30+
): ActionFilterResult {
31+
const action = searchParams.get('action')?.trim();
32+
const actionPrefix = searchParams.get('actionPrefix')?.trim();
33+
34+
if (action) {
35+
return { ok: true, clause: { eventType: { equals: action } } };
36+
}
37+
38+
if (!actionPrefix) {
39+
return { ok: true, clause: {} };
40+
}
41+
42+
if (actionPrefix.length < PREFIX_MIN || actionPrefix.length > PREFIX_MAX) {
43+
return {
44+
ok: false,
45+
error: `actionPrefix must be between ${PREFIX_MIN} and ${PREFIX_MAX} characters`,
46+
};
47+
}
48+
49+
return { ok: true, clause: { eventType: { startsWith: actionPrefix } } };
50+
}

0 commit comments

Comments
 (0)