Skip to content

Commit 0ad614b

Browse files
Copilotserhalp
andcommitted
Add comprehensive geolocation functionality with API, caching, and mode support
Co-authored-by: serhalp <1377702+serhalp@users.noreply.github.com>
1 parent c64e377 commit 0ad614b

File tree

3 files changed

+355
-1
lines changed

3 files changed

+355
-1
lines changed
Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
import { describe, expect, test, vi, beforeEach, afterEach } from 'vitest'
2+
3+
import { getGeoLocation, mockLocation } from './geo-location.js'
4+
5+
// Mock fetch
6+
global.fetch = vi.fn()
7+
const mockFetch = vi.mocked(fetch)
8+
9+
describe('geolocation', () => {
10+
let mockState: { get: vi.Mock; set: vi.Mock }
11+
12+
beforeEach(() => {
13+
vi.clearAllMocks()
14+
mockState = {
15+
get: vi.fn(),
16+
set: vi.fn(),
17+
}
18+
})
19+
20+
afterEach(() => {
21+
vi.restoreAllMocks()
22+
})
23+
24+
describe('getGeoLocation', () => {
25+
test('returns mock location when mode is "mock"', async () => {
26+
const result = await getGeoLocation({
27+
mode: 'mock',
28+
state: mockState,
29+
})
30+
31+
expect(result).toEqual(mockLocation)
32+
expect(mockState.get).not.toHaveBeenCalled()
33+
expect(mockState.set).not.toHaveBeenCalled()
34+
expect(mockFetch).not.toHaveBeenCalled()
35+
})
36+
37+
test('returns custom mock location when geoCountry is provided', async () => {
38+
const result = await getGeoLocation({
39+
mode: 'cache',
40+
geoCountry: 'FR',
41+
state: mockState,
42+
})
43+
44+
expect(result).toEqual({
45+
city: 'Mock City',
46+
country: { code: 'FR', name: 'Mock Country' },
47+
subdivision: { code: 'SD', name: 'Mock Subdivision' },
48+
longitude: 0,
49+
latitude: 0,
50+
timezone: 'UTC',
51+
})
52+
expect(mockFetch).not.toHaveBeenCalled()
53+
})
54+
55+
test('returns cached data when mode is "cache" and data is fresh', async () => {
56+
const cachedData = {
57+
city: 'Cached City',
58+
country: { code: 'CA', name: 'Canada' },
59+
subdivision: { code: 'ON', name: 'Ontario' },
60+
longitude: -79.3832,
61+
latitude: 43.6532,
62+
timezone: 'America/Toronto',
63+
}
64+
65+
mockState.get.mockReturnValue({
66+
data: cachedData,
67+
timestamp: Date.now() - 1000 * 60 * 60, // 1 hour ago
68+
})
69+
70+
const result = await getGeoLocation({
71+
mode: 'cache',
72+
state: mockState,
73+
})
74+
75+
expect(result).toEqual(cachedData)
76+
expect(mockState.get).toHaveBeenCalledWith('geolocation')
77+
expect(mockFetch).not.toHaveBeenCalled()
78+
})
79+
80+
test('fetches new data when mode is "cache" but data is stale', async () => {
81+
const staleData = {
82+
city: 'Stale City',
83+
country: { code: 'CA', name: 'Canada' },
84+
subdivision: { code: 'ON', name: 'Ontario' },
85+
longitude: -79.3832,
86+
latitude: 43.6532,
87+
timezone: 'America/Toronto',
88+
}
89+
90+
const freshData = {
91+
city: 'Fresh City',
92+
country: { code: 'US', name: 'United States' },
93+
subdivision: { code: 'NY', name: 'New York' },
94+
longitude: -74.006,
95+
latitude: 40.7128,
96+
timezone: 'America/New_York',
97+
}
98+
99+
mockState.get.mockReturnValue({
100+
data: staleData,
101+
timestamp: Date.now() - 1000 * 60 * 60 * 25, // 25 hours ago (stale)
102+
})
103+
104+
mockFetch.mockResolvedValue({
105+
json: () => Promise.resolve({ geo: freshData }),
106+
} as Response)
107+
108+
const result = await getGeoLocation({
109+
mode: 'cache',
110+
state: mockState,
111+
})
112+
113+
expect(result).toEqual(freshData)
114+
expect(mockState.get).toHaveBeenCalledWith('geolocation')
115+
expect(mockState.set).toHaveBeenCalledWith('geolocation', {
116+
data: freshData,
117+
timestamp: expect.any(Number),
118+
})
119+
expect(mockFetch).toHaveBeenCalledWith('https://netlifind.netlify.app', {
120+
method: 'GET',
121+
signal: expect.any(AbortSignal),
122+
})
123+
})
124+
125+
test('always fetches new data when mode is "update"', async () => {
126+
const cachedData = {
127+
city: 'Cached City',
128+
country: { code: 'CA', name: 'Canada' },
129+
subdivision: { code: 'ON', name: 'Ontario' },
130+
longitude: -79.3832,
131+
latitude: 43.6532,
132+
timezone: 'America/Toronto',
133+
}
134+
135+
const freshData = {
136+
city: 'Fresh City',
137+
country: { code: 'US', name: 'United States' },
138+
subdivision: { code: 'NY', name: 'New York' },
139+
longitude: -74.006,
140+
latitude: 40.7128,
141+
timezone: 'America/New_York',
142+
}
143+
144+
mockState.get.mockReturnValue({
145+
data: cachedData,
146+
timestamp: Date.now() - 1000 * 60 * 60, // 1 hour ago (fresh)
147+
})
148+
149+
mockFetch.mockResolvedValue({
150+
json: () => Promise.resolve({ geo: freshData }),
151+
} as Response)
152+
153+
const result = await getGeoLocation({
154+
mode: 'update',
155+
state: mockState,
156+
})
157+
158+
expect(result).toEqual(freshData)
159+
expect(mockState.set).toHaveBeenCalledWith('geolocation', {
160+
data: freshData,
161+
timestamp: expect.any(Number),
162+
})
163+
expect(mockFetch).toHaveBeenCalledWith('https://netlifind.netlify.app', {
164+
method: 'GET',
165+
signal: expect.any(AbortSignal),
166+
})
167+
})
168+
169+
test('uses cached data when offline is true, even if stale', async () => {
170+
const cachedData = {
171+
city: 'Cached City',
172+
country: { code: 'CA', name: 'Canada' },
173+
subdivision: { code: 'ON', name: 'Ontario' },
174+
longitude: -79.3832,
175+
latitude: 43.6532,
176+
timezone: 'America/Toronto',
177+
}
178+
179+
mockState.get.mockReturnValue({
180+
data: cachedData,
181+
timestamp: Date.now() - 1000 * 60 * 60 * 25, // 25 hours ago (stale)
182+
})
183+
184+
const result = await getGeoLocation({
185+
mode: 'cache',
186+
offline: true,
187+
state: mockState,
188+
})
189+
190+
expect(result).toEqual(cachedData)
191+
expect(mockFetch).not.toHaveBeenCalled()
192+
})
193+
194+
test('returns mock location when offline is true and no cached data', async () => {
195+
mockState.get.mockReturnValue(undefined)
196+
197+
const result = await getGeoLocation({
198+
mode: 'update',
199+
offline: true,
200+
state: mockState,
201+
})
202+
203+
expect(result).toEqual(mockLocation)
204+
expect(mockFetch).not.toHaveBeenCalled()
205+
})
206+
207+
test('returns mock location when API request fails', async () => {
208+
mockState.get.mockReturnValue(undefined)
209+
mockFetch.mockRejectedValue(new Error('Network error'))
210+
211+
const result = await getGeoLocation({
212+
mode: 'update',
213+
state: mockState,
214+
})
215+
216+
expect(result).toEqual(mockLocation)
217+
expect(mockFetch).toHaveBeenCalledWith('https://netlifind.netlify.app', {
218+
method: 'GET',
219+
signal: expect.any(AbortSignal),
220+
})
221+
})
222+
223+
test('uses cached data when country matches geoCountry', async () => {
224+
const cachedData = {
225+
city: 'Paris',
226+
country: { code: 'FR', name: 'France' },
227+
subdivision: { code: 'IDF', name: 'Île-de-France' },
228+
longitude: 2.3522,
229+
latitude: 48.8566,
230+
timezone: 'Europe/Paris',
231+
}
232+
233+
mockState.get.mockReturnValue({
234+
data: cachedData,
235+
timestamp: Date.now() - 1000 * 60 * 60 * 25, // 25 hours ago (stale)
236+
})
237+
238+
const result = await getGeoLocation({
239+
mode: 'update',
240+
geoCountry: 'FR',
241+
state: mockState,
242+
})
243+
244+
expect(result).toEqual(cachedData)
245+
expect(mockFetch).not.toHaveBeenCalled()
246+
})
247+
})
248+
})

packages/dev-utils/src/lib/geo-location.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,109 @@ export const mockLocation: Geolocation = {
1010
latitude: 0,
1111
timezone: 'UTC',
1212
}
13+
14+
const API_URL = 'https://netlifind.netlify.app'
15+
const STATE_GEO_PROPERTY = 'geolocation'
16+
// 24 hours
17+
const CACHE_TTL = 8.64e7
18+
19+
// 10 seconds
20+
const REQUEST_TIMEOUT = 1e4
21+
22+
interface State {
23+
get(key: string): unknown
24+
set(key: string, value: unknown): void
25+
}
26+
27+
/**
28+
* Returns geolocation data from a remote API, the local cache, or a mock location, depending on the
29+
* specified mode.
30+
*/
31+
export const getGeoLocation = async ({
32+
geoCountry,
33+
mode,
34+
offline = false,
35+
state,
36+
}: {
37+
mode: 'cache' | 'update' | 'mock'
38+
geoCountry?: string | undefined
39+
offline?: boolean | undefined
40+
state: State
41+
}): Promise<Geolocation> => {
42+
// Early return for pure mock mode (no geoCountry, no offline)
43+
if (mode === 'mock' && !geoCountry && !offline) {
44+
return mockLocation
45+
}
46+
47+
const cacheObject = state.get(STATE_GEO_PROPERTY) as { data: Geolocation; timestamp: number } | undefined
48+
49+
// If we have cached geolocation data and the `--geo` option is set to
50+
// `cache`, let's try to use it.
51+
// Or, if the country we're trying to mock is the same one as we have in the
52+
// cache, let's use the cache instead of the mock.
53+
if (cacheObject !== undefined && (mode === 'cache' || cacheObject.data.country?.code === geoCountry)) {
54+
const age = Date.now() - cacheObject.timestamp
55+
56+
// Let's use the cached data if it's not older than the TTL. Also, if the
57+
// `--offline` option was used, it's best to use the cached location than
58+
// the mock one.
59+
// Additionally, if we're trying to mock a country that matches the cached country,
60+
// prefer the cached data over the mock.
61+
if (age < CACHE_TTL || offline || cacheObject.data.country?.code === geoCountry) {
62+
return cacheObject.data
63+
}
64+
}
65+
66+
// If `--country` was used, we also set `--mode=mock`.
67+
if (geoCountry) {
68+
mode = 'mock'
69+
}
70+
71+
// If the `--geo` option is set to `mock`, we use the default mock location.
72+
// If the `--offline` option was used, we can't talk to the API, so let's
73+
// also use the mock location. Otherwise, use the country code passed in by
74+
// the user.
75+
if (mode === 'mock' || offline || geoCountry) {
76+
if (geoCountry) {
77+
return {
78+
city: 'Mock City',
79+
country: { code: geoCountry, name: 'Mock Country' },
80+
subdivision: { code: 'SD', name: 'Mock Subdivision' },
81+
longitude: 0,
82+
latitude: 0,
83+
timezone: 'UTC',
84+
}
85+
}
86+
return mockLocation
87+
}
88+
89+
// Trying to retrieve geolocation data from the API and caching it locally.
90+
try {
91+
const data = await getGeoLocationFromAPI()
92+
const newCacheObject = {
93+
data,
94+
timestamp: Date.now(),
95+
}
96+
97+
state.set(STATE_GEO_PROPERTY, newCacheObject)
98+
99+
return data
100+
} catch {
101+
// We couldn't get geolocation data from the API, so let's return the
102+
// mock location.
103+
return mockLocation
104+
}
105+
}
106+
107+
/**
108+
* Returns geolocation data from a remote API.
109+
*/
110+
const getGeoLocationFromAPI = async (): Promise<Geolocation> => {
111+
const res = await fetch(API_URL, {
112+
method: 'GET',
113+
signal: AbortSignal.timeout(REQUEST_TIMEOUT),
114+
})
115+
const { geo } = await res.json() as { geo: Geolocation }
116+
117+
return geo
118+
}

packages/dev-utils/src/main.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ export { getAPIToken } from './lib/api-token.js'
22
export { shouldBase64Encode } from './lib/base64.js'
33
export { renderFunctionErrorPage } from './lib/errors.js'
44
export { DevEvent, DevEventHandler } from './lib/event.js'
5-
export { type Geolocation, mockLocation } from './lib/geo-location.js'
5+
export { type Geolocation, mockLocation, getGeoLocation } from './lib/geo-location.js'
66
export { ensureNetlifyIgnore } from './lib/gitignore.js'
77
export { headers, toMultiValueHeaders } from './lib/headers.js'
88
export * as globalConfig from './lib/global-config.js'

0 commit comments

Comments
 (0)