@thumbmarkjs/thumbmarkjs
Version:
   • 12.5 kB
text/typescript
import { apiResponse, getCachedApiResponse, setCachedApiResponse, getApiPromise } from "./api";
import { defaultOptions } from "../options";
import { getCache, setCache } from "../utils/cache";
import { setVisitorId } from "../utils/visitorId";
import type { componentInterface } from "../factory";
import * as hashModule from "../utils/hash";
import * as stringifyModule from "../utils/stableStringify";
const options = {
...defaultOptions,
cache_lifetime_in_ms: 100,
}
const apiResponse = {
identifier: 'test'
} as apiResponse;
describe('setCachedApiResponse', () => {
beforeEach(() => {
localStorage.clear();
})
test('it should write the apiResponse to the cache if options allow that', () => {
setCachedApiResponse(options, apiResponse);
expect(getCache(options).apiResponse).toEqual(apiResponse);
});
test('it should not write if cache if off', () => {
setCachedApiResponse({
...options,
cache_api_call: false,
}, apiResponse);
expect(getCache(options).apiResponse).not.toBeDefined();
expect(getCache(options).apiResponseExpiry).not.toBeDefined();
});
test('it should not write if lifetime is 0', () => {
setCachedApiResponse({
...options,
cache_lifetime_in_ms: 0
}, apiResponse);
expect(getCache(options).apiResponse).not.toBeDefined();
expect(getCache(options).apiResponseExpiry).not.toBeDefined();
});
})
describe('getCachedApiResponse', () => {
beforeEach(() => {
localStorage.clear();
})
test('it should get from the cache if a value exists there', () => {
setCache(options, {
apiResponseExpiry: Date.now() + 2000000,
apiResponse,
});
const cached = getCachedApiResponse(options);
expect(cached).toBeDefined();
expect(cached).toEqual(apiResponse);
});
test('it should not return an expiried cache', () => {
setCache(options, {
apiResponseExpiry: Date.now() - 2000,
apiResponse,
});
const cached = getCachedApiResponse(options);
expect(cached).not.toBeDefined();
})
})
describe('getApiPromise timeout behavior', () => {
let mockFetch: jest.Mock;
const testOptions = {
...defaultOptions,
api_key: 'test-api-key',
timeout: 100, // Short timeout for tests
cache_lifetime_in_ms: 1000,
};
const testComponents: componentInterface = {
userAgent: 'test-agent',
screen: { width: 1920, height: 1080 },
};
beforeAll(() => {
// Polyfill TextEncoder for jsdom environment
if (typeof TextEncoder === 'undefined') {
const util = require('util');
global.TextEncoder = util.TextEncoder;
global.TextDecoder = util.TextDecoder;
}
// Mock hash and stableStringify to avoid TextEncoder issues
jest.spyOn(hashModule, 'hash').mockReturnValue('mocked-hash');
jest.spyOn(stringifyModule, 'stableStringify').mockReturnValue('{"mocked":"data"}');
jest.useFakeTimers();
});
afterAll(() => {
jest.useRealTimers();
});
beforeEach(async () => {
// Clear all pending timers and promises from previous tests
jest.clearAllTimers();
localStorage.clear();
mockFetch = jest.fn();
global.fetch = mockFetch;
// Re-mock hash and stableStringify for each test
jest.spyOn(hashModule, 'hash').mockReturnValue('mocked-hash');
jest.spyOn(stringifyModule, 'stableStringify').mockReturnValue('{"mocked":"data"}');
});
afterEach(() => {
jest.restoreAllMocks();
});
test('returns fetch response when fetch completes before timeout', async () => {
// Use options without caching to avoid state interference
const noCacheOptions = {
...testOptions,
cache_lifetime_in_ms: 0, // Disable caching
};
// Mock fetch to resolve quickly
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
version: '1.2.3',
visitorId: 'test-visitor-id',
thumbmark: 'test-thumbmark',
requestId: 'test-request-id',
info: {
ip_address: {
ip_address: '1.2.3.4',
ip_identifier: 'abc123',
autonomous_system_number: 12345,
ip_version: 'v4' as const,
}
}
})
});
const promise = getApiPromise(noCacheOptions, testComponents);
// Fast forward time, but fetch completes before timeout
await jest.runAllTimersAsync();
const result = await promise;
expect(result).toBeDefined();
expect(result?.version).toBe('1.2.3');
expect(result?.info?.timed_out).toBeUndefined();
expect(result?.thumbmark).toBe('test-thumbmark');
expect(result?.requestId).toBe('test-request-id');
});
test('returns expired cache when timeout occurs and cache exists', async () => {
// Disable caching to test timeout fallback without request deduplication
const noCacheOptions = {
...testOptions,
cache_api_call: false,
};
// Set up expired cache
const expiredCachedValue = {
apiResponseExpiry: Date.now() - 100, // Expired
apiResponse: {
version: '1.2.3',
thumbmark: 'cached-thumbmark',
info: {
ip_address: {
ip_address: '1.2.3.4',
ip_identifier: 'abc123',
autonomous_system_number: 12345,
ip_version: 'v4' as const,
}
}
}
};
setCache(noCacheOptions, expiredCachedValue);
// Mock fetch to never resolve (hanging request)
mockFetch.mockReturnValueOnce(new Promise(() => { }));
const promise = getApiPromise(noCacheOptions, testComponents);
// Advance timers to trigger timeout
await jest.runAllTimersAsync();
const result = await promise;
// Should return expired cache, not timeout response
expect(result).toBeDefined();
expect(result?.version).toBe('1.2.3');
expect(result?.thumbmark).toBe('cached-thumbmark');
expect(result?.info?.timed_out).toBeUndefined();
});
test('returns timeout response when timeout occurs and no cache exists', async () => {
const noCacheOptions = {
...testOptions,
cache_api_call: false,
};
expect(getCache(noCacheOptions)).toEqual({});
mockFetch.mockReturnValueOnce(new Promise(() => { }));
const promise = getApiPromise(noCacheOptions, testComponents);
await jest.runAllTimersAsync();
const result = await promise;
expect(result).toBeDefined();
expect(result?.info?.timed_out).toBe(true);
const cached = getCache(noCacheOptions);
expect(cached.apiResponse).toBeUndefined();
});
test('returns timeout response without visitorId when no cache and no stored visitorId', async () => {
const noCacheOptions = {
...testOptions,
cache_api_call: false,
};
// Ensure no cache and no visitor ID
expect(getCache(noCacheOptions)).toEqual({});
expect(localStorage.getItem('thumbmark_visitor_id')).toBeNull();
mockFetch.mockReturnValueOnce(new Promise(() => { }));
const promise = getApiPromise(noCacheOptions, testComponents);
await jest.runAllTimersAsync();
const result = await promise;
expect(result).toBeDefined();
expect(result?.info?.timed_out).toBe(true);
expect(result?.visitorId).toBeUndefined();
});
test('returns timeout response with visitorId when no cache but visitorId exists in localStorage', async () => {
const noCacheOptions = {
...testOptions,
cache_api_call: false,
};
// Set up visitor ID in localStorage (no cache)
const storedVisitorId = 'stored-visitor-id-123';
setVisitorId(storedVisitorId, noCacheOptions);
// Verify setup: no cache, but visitor ID exists
expect(getCache(noCacheOptions)).toEqual({});
expect(localStorage.getItem('thumbmark_visitor_id')).toBe(storedVisitorId);
mockFetch.mockReturnValueOnce(new Promise(() => { }));
const promise = getApiPromise(noCacheOptions, testComponents);
await jest.runAllTimersAsync();
const result = await promise;
expect(result).toBeDefined();
expect(result?.info?.timed_out).toBe(true);
expect(result?.visitorId).toBe(storedVisitorId);
});
test('retries on network error and succeeds on second attempt', async () => {
jest.useRealTimers();
const noCacheOptions = { ...testOptions, cache_api_call: false, timeout: 5000 };
mockFetch
.mockRejectedValueOnce(new TypeError('Failed to fetch'))
.mockResolvedValueOnce({
ok: true, status: 200,
json: async () => ({ version: '1.0', thumbmark: 'retry-ok' }),
});
const result = await getApiPromise(noCacheOptions, testComponents);
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(result?.thumbmark).toBe('retry-ok');
jest.useFakeTimers();
});
test('does not retry on 500 server error', async () => {
jest.useRealTimers();
const noCacheOptions = { ...testOptions, cache_api_call: false, timeout: 5000 };
mockFetch.mockResolvedValueOnce({ ok: false, status: 500 });
await expect(getApiPromise(noCacheOptions, testComponents)).rejects.toThrow('HTTP error! status: 500');
expect(mockFetch).toHaveBeenCalledTimes(1);
jest.useFakeTimers();
});
test('does not retry on 403 auth error', async () => {
jest.useRealTimers();
const noCacheOptions = { ...testOptions, cache_api_call: false, timeout: 5000 };
mockFetch.mockResolvedValueOnce({ ok: false, status: 403 });
await expect(getApiPromise(noCacheOptions, testComponents)).rejects.toThrow('HTTP error! status: 403');
expect(mockFetch).toHaveBeenCalledTimes(1);
jest.useFakeTimers();
});
test('network errors exhaust retries then throw', async () => {
jest.useRealTimers();
const noCacheOptions = { ...testOptions, cache_api_call: false, timeout: 5000 };
mockFetch.mockRejectedValue(new TypeError('Failed to fetch'));
await expect(getApiPromise(noCacheOptions, testComponents)).rejects.toThrow('Failed to fetch');
expect(mockFetch).toHaveBeenCalledTimes(3);
jest.useFakeTimers();
});
test('returns full cached response (not just visitorId) when cache exists on timeout', async () => {
const noCacheOptions = {
...testOptions,
cache_api_call: false,
};
// Set up both: expired cache AND visitor ID in localStorage
const storedVisitorId = 'stored-visitor-id-456';
setVisitorId(storedVisitorId, noCacheOptions);
const expiredCachedValue = {
apiResponseExpiry: Date.now() - 100, // Expired
apiResponse: {
version: '1.2.3',
thumbmark: 'cached-thumbmark',
visitorId: 'cached-visitor-id',
info: {
uniqueness: { score: 0.95 }
}
}
};
setCache(noCacheOptions, expiredCachedValue);
mockFetch.mockReturnValueOnce(new Promise(() => { }));
const promise = getApiPromise(noCacheOptions, testComponents);
await jest.runAllTimersAsync();
const result = await promise;
// Should return full cached response, not just visitor ID fallback
expect(result).toBeDefined();
expect(result?.info?.timed_out).toBeUndefined();
expect(result?.thumbmark).toBe('cached-thumbmark');
expect(result?.visitorId).toBe('cached-visitor-id'); // From cache, not localStorage
expect(result?.version).toBe('1.2.3');
});
})