Skip to content

Commit b4e0e85

Browse files
Copilotserhalp
andauthored
test: add comprehensive test coverage for getCacheAnalysis.ts (#79)
* Initial plan for issue * Add comprehensive test coverage for getCacheAnalysis.ts Co-authored-by: serhalp <[email protected]> * Address PR feedback: fix NaN bug, refactor tests, export functions Co-authored-by: serhalp <[email protected]> * Extract functions to separate files and fix PR feedback Co-authored-by: serhalp <[email protected]> * Address PR feedback: fix test, remove package-lock.json, revert vitest config, extract getTimeToLive Co-authored-by: serhalp <[email protected]> * test: improve cache-control test coverage --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: serhalp <[email protected]> Co-authored-by: Philippe Serhal <[email protected]>
1 parent 61ffe9b commit b4e0e85

9 files changed

+923
-269
lines changed

app/utils/cache-control.test.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { describe, it, expect } from 'vitest'
2+
3+
import { parse, CacheControl } from './cache-control'
4+
5+
describe('parse', () => {
6+
it('parses max-age directive from cache control header', () => {
7+
const result = parse('max-age=3600')
8+
9+
expect(result.maxAge).toBe(3600)
10+
})
11+
12+
it('parses public directive from cache control header', () => {
13+
expect(parse('public, max-age=5')).toHaveProperty('public', true)
14+
expect(parse('max-age=5, public')).toHaveProperty('public', true)
15+
expect(parse('max-age=5')).toHaveProperty('public', false)
16+
})
17+
18+
it('parses s-maxage directive from cache control header', () => {
19+
const result = parse('s-maxage=7200')
20+
21+
expect(result.sharedMaxAge).toBe(7200)
22+
})
23+
24+
it('parses must-revalidate directive from cache control header', () => {
25+
expect(parse('public, must-revalidate')).toHaveProperty('mustRevalidate', true)
26+
expect(parse('must-revalidate, public')).toHaveProperty('mustRevalidate', true)
27+
expect(parse('max-age=5, public')).toHaveProperty('mustRevalidate', false)
28+
})
29+
30+
it('parses immutable directive from cache control header', () => {
31+
expect(parse('public, immutable')).toHaveProperty('immutable', true)
32+
expect(parse('immutable, public')).toHaveProperty('immutable', true)
33+
expect(parse('max-age=5, public')).toHaveProperty('immutable', false)
34+
})
35+
36+
it('parses private directive from cache control header', () => {
37+
expect(parse('max-age=5, private')).toHaveProperty('private', true)
38+
expect(parse('private, max-age=5')).toHaveProperty('private', true)
39+
expect(parse('max-age=5, public')).toHaveProperty('private', false)
40+
})
41+
42+
it('parses no-store directive from cache control header', () => {
43+
expect(parse('max-age=5, no-store')).toHaveProperty('noStore', true)
44+
expect(parse('no-store, max-age=5')).toHaveProperty('noStore', true)
45+
expect(parse('max-age=5, public')).toHaveProperty('noStore', false)
46+
})
47+
48+
it('parses no-cache directive from cache control header', () => {
49+
expect(parse('max-age=5, no-cache')).toHaveProperty('noCache', true)
50+
expect(parse('no-cache, max-age=5')).toHaveProperty('noCache', true)
51+
expect(parse('max-age=5, public')).toHaveProperty('noCache', false)
52+
})
53+
54+
it('returns null values when header is empty', () => {
55+
const result = parse('')
56+
57+
expect(result.maxAge).toBe(null)
58+
expect(result.public).toBe(null)
59+
})
60+
61+
it('returns null values when header is null', () => {
62+
const result = parse(null)
63+
64+
expect(result.maxAge).toBe(null)
65+
expect(result.public).toBe(null)
66+
})
67+
68+
it('returns null values when header is undefined', () => {
69+
const result = parse(undefined)
70+
71+
expect(result.maxAge).toBe(null)
72+
expect(result.public).toBe(null)
73+
})
74+
75+
it('can be instantiated and used directly', () => {
76+
const cc = new CacheControl()
77+
const result = cc.parse('max-age=1800')
78+
79+
expect(result.maxAge).toBe(1800)
80+
})
81+
82+
it('parses extension directives without values', () => {
83+
const result = parse('max-age=3600, durable')
84+
85+
expect(result.maxAge).toBe(3600)
86+
expect(result.extensions).toHaveProperty('durable', null)
87+
})
88+
89+
it('parses extension directives with values', () => {
90+
const result = parse('max-age=3600, fishiness=42')
91+
92+
expect(result.maxAge).toBe(3600)
93+
expect(result.extensions).toHaveProperty('fishiness', '42')
94+
})
95+
})

app/utils/getCacheAnalysis.test.ts

Lines changed: 138 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,93 +1,165 @@
11
import { describe, it, expect } from 'vitest'
22

3-
import { getTimeToLive } from './getCacheAnalysis'
3+
import getCacheAnalysis, { parseCacheStatus } from './getCacheAnalysis'
4+
import { ServedBySource } from './getServedBy'
45

56
describe('getCacheAnalysis', () => {
6-
it.todo('works')
7-
})
7+
it('returns cache analysis structure with basic CDN headers', () => {
8+
const headers = {
9+
'Cache-Status': '"Netlify Edge"; hit',
10+
'Debug-X-BB-Host-Id': 'node1.example.com',
11+
}
12+
const now = Date.now()
13+
14+
const result = getCacheAnalysis(headers, now)
15+
16+
expect(result).toHaveProperty('servedBy')
17+
expect(result).toHaveProperty('cacheStatus')
18+
expect(result).toHaveProperty('cacheControl')
19+
expect(result.servedBy.source).toBe(ServedBySource.CDN)
20+
})
821

9-
describe('getTimeToLive', () => {
10-
it('returns the diff in seconds from `maxAge` to `age` if they are both defined', () => {
11-
const age = 10
12-
const date = undefined
13-
const expiresAt = undefined
14-
const maxAge = 25
22+
it('integrates Cache-Status parsing correctly', () => {
23+
const headers = {
24+
'Cache-Status': '"Next.js"; hit, "Netlify Edge"; hit',
25+
'Debug-X-BB-Host-Id': 'node1.example.com',
26+
}
1527
const now = Date.now()
1628

17-
expect(getTimeToLive(age, date, expiresAt, maxAge, now)).toBe(15)
29+
const result = getCacheAnalysis(headers, now)
30+
31+
expect(result.cacheStatus).toHaveLength(2)
32+
expect(result.cacheStatus[0]?.cacheName).toBe('Netlify Edge')
33+
expect(result.cacheStatus[1]?.cacheName).toBe('Next.js')
1834
})
1935

20-
it('returns the diff in seconds from `maxAge` to `age` if they are both defined as well as `date`', () => {
21-
const age = 10
22-
const date = new Date(999_999_999)
23-
const expiresAt = undefined
24-
const maxAge = 25
36+
it('integrates cache control parsing correctly', () => {
37+
const headers = {
38+
'Cache-Control': 'max-age=3600',
39+
'Cache-Status': '"Netlify Edge"; hit',
40+
'Debug-X-BB-Host-Id': 'node1.example.com',
41+
}
2542
const now = Date.now()
2643

27-
expect(getTimeToLive(age, date, expiresAt, maxAge, now)).toBe(15)
44+
const result = getCacheAnalysis(headers, now)
45+
46+
expect(result.cacheControl.isCacheable).toBe(true)
47+
expect(typeof result.cacheControl.ttl).toBe('number')
2848
})
2949

30-
it('returns the diff in seconds from `maxAge` to `now - date` if `maxAge` and `date` are defined but not `age`', () => {
31-
const age = undefined
32-
const date = new Date(1_000_000)
33-
const expiresAt = undefined
34-
const maxAge = 25
35-
const now = new Date(1_000_000 + 10_000).getTime()
50+
it('integrates served by determination correctly', () => {
51+
const headers = {
52+
'Cache-Status': '"Netlify Durable"; hit',
53+
'Debug-X-BB-Host-Id': 'node1.example.com',
54+
}
55+
const now = Date.now()
56+
57+
const result = getCacheAnalysis(headers, now)
3658

37-
expect(getTimeToLive(age, date, expiresAt, maxAge, now)).toBe(15)
59+
expect(result.servedBy.source).toBe(ServedBySource.DurableCache)
60+
expect(result.servedBy.cdnNodes).toBe('node1.example.com')
3861
})
3962

40-
it('returns the diff in seconds from `maxAge` to `now` if `maxAge` is defined but neither `age` nor `date`', () => {
41-
const age = undefined
42-
const date = undefined
43-
const expiresAt = undefined
44-
const maxAge = 25
45-
const now = new Date(1_000_000 + 10_000).getTime()
63+
it('handles empty headers gracefully', () => {
64+
const headers = {}
65+
const now = Date.now()
4666

47-
expect(getTimeToLive(age, date, expiresAt, maxAge, now)).toBe(25)
67+
expect(() => getCacheAnalysis(headers, now)).toThrow('Could not determine who served the request')
4868
})
69+
})
70+
71+
describe('parseCacheStatus', () => {
72+
it('parses single cache status entry with hit parameter', () => {
73+
const cacheStatus = '"Netlify Edge"; hit'
74+
75+
const result = parseCacheStatus(cacheStatus)
76+
77+
expect(result).toEqual([
78+
{
79+
cacheName: 'Netlify Edge',
80+
parameters: {
81+
'hit': true,
82+
'fwd': undefined,
83+
'fwd-status': undefined,
84+
'ttl': undefined,
85+
'stored': false,
86+
'collapsed': false,
87+
'key': undefined,
88+
'detail': undefined,
89+
},
90+
},
91+
])
92+
})
93+
94+
it('parses multiple cache status entries with different parameters', () => {
95+
const cacheStatus = '"Next.js"; hit, "Netlify Durable"; fwd=miss; stored, "Netlify Edge"; fwd=miss'
4996

50-
it('returns the diff in seconds from `expiresAt` to `now - age` if `maxAge` and `date` are not defined and `expiresAt` and `age` are defined', () => {
51-
const age = 10
52-
const date = undefined
53-
const expiresAt = new Date(1_000_000 + 25_000)
54-
const maxAge = null
55-
const now = new Date(1_000_000 + 10_000).getTime()
97+
const result = parseCacheStatus(cacheStatus)
5698

57-
expect(getTimeToLive(age, date, expiresAt, maxAge, now)).toBe(15)
99+
expect(result).toHaveLength(3)
100+
expect(result[0]?.cacheName).toBe('Netlify Edge') // Should be reversed per spec
101+
expect(result[1]?.cacheName).toBe('Netlify Durable')
102+
expect(result[2]?.cacheName).toBe('Next.js')
103+
expect(result[0]?.parameters.fwd).toBe('miss')
104+
expect(result[1]?.parameters.stored).toBe(true)
105+
expect(result[2]?.parameters.hit).toBe(true)
58106
})
59107

60-
// FIXME(serhalp) Real bug. Fix logic. It's depending on `now` when given two absolute dates...
61-
it.fails(
62-
'returns the diff in seconds from `expiresAt` to `date` if `maxAge` and `age` are not defined and `expiresAt` and `date` are defined',
63-
() => {
64-
const age = undefined
65-
const date = new Date(1_000_000)
66-
const expiresAt = new Date(1_000_000 + 15_000)
67-
const maxAge = null
68-
const now = Date.now()
69-
70-
expect(getTimeToLive(age, date, expiresAt, maxAge, now)).toBe(15)
71-
},
72-
)
73-
74-
it('returns the diff in seconds from `expiresAt` to `now` if `maxAge`, `age`, and `date` are not defined and `expiresAt` is defined', () => {
75-
const age = undefined
76-
const date = undefined
77-
const expiresAt = new Date(1_000_000 + 15_000)
78-
const maxAge = null
79-
const now = new Date(1_000_000).getTime()
80-
81-
expect(getTimeToLive(age, date, expiresAt, maxAge, now)).toBe(15)
108+
it('parses cache status with numeric parameters', () => {
109+
const cacheStatus = '"Netlify Edge"; hit; fwd-status=200; ttl=3600'
110+
111+
const result = parseCacheStatus(cacheStatus)
112+
113+
expect(result[0]?.parameters['fwd-status']).toBe(200)
114+
expect(result[0]?.parameters.ttl).toBe(3600)
82115
})
83116

84-
it('returns `undefined` if `maxAge` and `expiresAt` are not defined', () => {
85-
const age = 10
86-
const date = new Date()
87-
const expiresAt = undefined
88-
const maxAge = null
89-
const now = Date.now()
117+
it('parses cache status with string parameters', () => {
118+
const cacheStatus = '"Netlify Edge"; hit; key=cache-key-123; detail=some-detail'
119+
120+
const result = parseCacheStatus(cacheStatus)
121+
122+
expect(result[0]?.parameters.key).toBe('cache-key-123')
123+
expect(result[0]?.parameters.detail).toBe('some-detail')
124+
})
125+
126+
it('handles empty cache status string', () => {
127+
const result = parseCacheStatus('')
128+
129+
expect(result).toEqual([])
130+
})
131+
132+
it('ignores invalid cache status entries without parameters', () => {
133+
const cacheStatus = '"Netlify Edge", "Valid Cache"; hit'
134+
135+
const result = parseCacheStatus(cacheStatus)
136+
137+
expect(result).toHaveLength(1)
138+
expect(result[0]?.cacheName).toBe('Valid Cache')
139+
})
140+
141+
it('ignores cache status entries with invalid parameters', () => {
142+
const cacheStatus = '"Netlify Edge"; ; hit'
143+
144+
const result = parseCacheStatus(cacheStatus)
145+
146+
expect(result).toHaveLength(1)
147+
expect(result[0]?.cacheName).toBe('Netlify Edge')
148+
expect(result[0]?.parameters.hit).toBe(true)
149+
})
150+
151+
it('sorts cache entries according to RFC 9211 precedence', () => {
152+
const cacheStatus = '"Netlify Edge"; hit, "Next.js"; hit, "Netlify Durable"; hit'
153+
154+
const result = parseCacheStatus(cacheStatus)
155+
156+
expect(result[0]?.cacheName).toBe('Netlify Edge')
157+
expect(result[1]?.cacheName).toBe('Netlify Durable')
158+
expect(result[2]?.cacheName).toBe('Next.js')
159+
})
90160

91-
expect(getTimeToLive(age, date, expiresAt, maxAge, now)).toBeUndefined()
161+
it('parses forward parameter values from cache status entries', () => {
162+
const result = parseCacheStatus('"Cache"; fwd=bypass')
163+
expect(result[0]?.parameters.fwd).toBe('bypass')
92164
})
93165
})

0 commit comments

Comments
 (0)