@tinytapanalytics/sdk
Version:
Behavioral psychology platform that detects visitor frustration, predicts abandonment, and helps you save at-risk conversions in real-time
734 lines (580 loc) • 20.8 kB
text/typescript
/**
* Tests for MinimalTinyTapAnalytics SDK
*/
import { MinimalTinyTapAnalytics } from '../minimal';
import packageJson from '../../package.json';
// Mock fetch globally
const mockFetch = jest.fn();
global.fetch = mockFetch as any;
// Mock navigator.userAgent
Object.defineProperty(navigator, 'userAgent', {
value: 'Mozilla/5.0 (Test Browser)',
configurable: true
});
describe('MinimalTinyTapAnalytics', () => {
let sdk: MinimalTinyTapAnalytics;
const mockConfig = {
apiKey: 'test-api-key',
websiteId: 'test-website-id',
endpoint: 'https://test-endpoint.com',
debug: false
};
beforeEach(() => {
jest.clearAllMocks();
mockFetch.mockResolvedValue({
ok: true,
status: 202,
statusText: 'Accepted'
});
});
describe('Constructor', () => {
it('should initialize with provided config', () => {
sdk = new MinimalTinyTapAnalytics(mockConfig);
expect(sdk).toBeDefined();
});
it('should use default endpoint if not provided', () => {
const sdkWithDefaults = new MinimalTinyTapAnalytics({
apiKey: 'test-key',
websiteId: 'test-id'
});
expect(sdkWithDefaults).toBeDefined();
});
it('should generate a session ID on initialization', () => {
sdk = new MinimalTinyTapAnalytics(mockConfig);
// The session ID should be set internally
expect(sdk).toBeDefined();
});
});
describe('track()', () => {
beforeEach(() => {
sdk = new MinimalTinyTapAnalytics(mockConfig);
});
it('should throw error when websiteId is not configured', async () => {
const sdkWithoutWebsiteId = new MinimalTinyTapAnalytics({
apiKey: 'test-key'
});
await expect(sdkWithoutWebsiteId.track('test_event')).rejects.toThrow(
'TinyTapAnalytics: websiteId is required but not configured'
);
});
it('should call correct endpoint with /api/v1/events path', async () => {
await sdk.track('custom_event', { foo: 'bar' });
expect(mockFetch).toHaveBeenCalledWith(
'https://test-endpoint.com/api/v1/events',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
'Content-Type': 'application/json',
'Authorization': 'Bearer test-api-key'
})
})
);
});
it('should send event with required fields', async () => {
await sdk.track('custom_event', { custom_data: 'test' });
expect(mockFetch).toHaveBeenCalled();
const callArgs = mockFetch.mock.calls[0];
const body = JSON.parse(callArgs[1].body);
expect(body).toMatchObject({
website_id: 'test-website-id',
event_type: 'custom_event',
session_id: expect.any(String),
timestamp: expect.any(String),
user_agent: expect.any(String),
page_url: expect.any(String)
});
});
it('should include metadata with viewport dimensions and SDK version', async () => {
await sdk.track('custom_event', { foo: 'bar' });
const callArgs = mockFetch.mock.calls[0];
const body = JSON.parse(callArgs[1].body);
expect(body.metadata).toMatchObject({
foo: 'bar',
viewport_width: expect.any(Number),
viewport_height: expect.any(Number),
sdk_version: `${packageJson.version}-minimal`
});
});
it('should include user_id when set via identify()', async () => {
mockFetch.mockClear();
sdk.identify('user-123');
// identify() calls track('identify'), so clear that call
mockFetch.mockClear();
await sdk.track('custom_event');
const callArgs = mockFetch.mock.calls[0];
const body = JSON.parse(callArgs[1].body);
expect(body.user_id).toBe('user-123');
});
it('should handle fetch errors gracefully', async () => {
mockFetch.mockRejectedValue(new Error('Network error'));
await expect(sdk.track('custom_event')).rejects.toThrow('Network error');
});
it('should handle HTTP error responses', async () => {
mockFetch.mockResolvedValue({
ok: false,
status: 400,
statusText: 'Bad Request'
});
await expect(sdk.track('custom_event')).rejects.toThrow('HTTP 400: Bad Request');
});
it('should log debug info when debug mode is enabled', async () => {
const consoleSpy = jest.spyOn(console, 'log').mockImplementation();
const debugSdk = new MinimalTinyTapAnalytics({
...mockConfig,
debug: true
});
await debugSdk.track('custom_event');
expect(consoleSpy).toHaveBeenCalledWith(
'TinyTapAnalytics: Event tracked',
expect.any(Object)
);
consoleSpy.mockRestore();
});
it('should log errors in debug mode', async () => {
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
const debugSdk = new MinimalTinyTapAnalytics({
...mockConfig,
debug: true
});
mockFetch.mockRejectedValue(new Error('Test error'));
await expect(debugSdk.track('custom_event')).rejects.toThrow();
expect(consoleErrorSpy).toHaveBeenCalledWith(
'TinyTapAnalytics: Failed to track event',
expect.any(Error)
);
consoleErrorSpy.mockRestore();
});
});
describe('identify()', () => {
beforeEach(() => {
sdk = new MinimalTinyTapAnalytics(mockConfig);
});
it('should set user ID and track identify event', async () => {
await sdk.identify('user-456');
expect(mockFetch).toHaveBeenCalled();
const callArgs = mockFetch.mock.calls[0];
const body = JSON.parse(callArgs[1].body);
expect(body.event_type).toBe('identify');
expect(body.user_id).toBe('user-456');
expect(body.metadata.user_id).toBe('user-456');
});
it('should persist user ID for subsequent events', async () => {
mockFetch.mockClear();
sdk.identify('user-789');
mockFetch.mockClear();
await sdk.track('custom_event');
const callArgs = mockFetch.mock.calls[0];
const body = JSON.parse(callArgs[1].body);
expect(body.user_id).toBe('user-789');
});
});
describe('trackConversion()', () => {
beforeEach(() => {
sdk = new MinimalTinyTapAnalytics(mockConfig);
});
it('should track conversion with value and currency', async () => {
await sdk.trackConversion(99.99, 'USD', { product_id: 'prod-123' });
const callArgs = mockFetch.mock.calls[0];
const body = JSON.parse(callArgs[1].body);
expect(body.event_type).toBe('conversion');
expect(body.metadata).toMatchObject({
value: 99.99,
currency: 'USD',
product_id: 'prod-123'
});
});
it('should default to USD if currency not provided', async () => {
await sdk.trackConversion(50);
const callArgs = mockFetch.mock.calls[0];
const body = JSON.parse(callArgs[1].body);
expect(body.metadata.currency).toBe('USD');
expect(body.metadata.value).toBe(50);
});
});
describe('trackPageView()', () => {
beforeEach(() => {
sdk = new MinimalTinyTapAnalytics(mockConfig);
});
it('should track page view with URL, title, and referrer', async () => {
// Mock document properties
Object.defineProperty(document, 'title', {
value: 'Test Page',
configurable: true
});
Object.defineProperty(document, 'referrer', {
value: 'https://referrer.com',
configurable: true
});
await sdk.trackPageView();
const callArgs = mockFetch.mock.calls[0];
const body = JSON.parse(callArgs[1].body);
expect(body.event_type).toBe('page_view');
expect(body.metadata).toMatchObject({
url: expect.any(String),
title: 'Test Page',
referrer: 'https://referrer.com'
});
});
});
describe('trackClick()', () => {
beforeEach(() => {
sdk = new MinimalTinyTapAnalytics(mockConfig);
});
it('should track click with element selector', async () => {
await sdk.trackClick('#submit-button', { action: 'submit' });
const callArgs = mockFetch.mock.calls[0];
const body = JSON.parse(callArgs[1].body);
expect(body.event_type).toBe('click');
expect(body.metadata).toMatchObject({
element: '#submit-button',
page_url: expect.any(String),
action: 'submit'
});
});
it('should track click without metadata', async () => {
await sdk.trackClick('.nav-link');
const callArgs = mockFetch.mock.calls[0];
const body = JSON.parse(callArgs[1].body);
expect(body.event_type).toBe('click');
expect(body.metadata.element).toBe('.nav-link');
});
});
describe('Session ID generation', () => {
it('should generate unique session IDs', () => {
const sdk1 = new MinimalTinyTapAnalytics(mockConfig);
const sdk2 = new MinimalTinyTapAnalytics(mockConfig);
// Session IDs should be different for different instances
expect(sdk1).not.toBe(sdk2);
});
it('should generate session ID with correct format', async () => {
sdk = new MinimalTinyTapAnalytics(mockConfig);
await sdk.track('test_event');
const callArgs = mockFetch.mock.calls[0];
const body = JSON.parse(callArgs[1].body);
expect(body.session_id).toMatch(/^ciq_[a-z0-9]+$/);
});
});
describe('Error handling', () => {
beforeEach(() => {
sdk = new MinimalTinyTapAnalytics(mockConfig);
});
it('should handle network errors', async () => {
mockFetch.mockRejectedValue(new Error('Network failure'));
await expect(sdk.track('test_event')).rejects.toThrow('Network failure');
});
it('should handle server errors (500)', async () => {
mockFetch.mockResolvedValue({
ok: false,
status: 500,
statusText: 'Internal Server Error'
});
await expect(sdk.track('test_event')).rejects.toThrow('HTTP 500: Internal Server Error');
});
it('should handle validation errors (400)', async () => {
mockFetch.mockResolvedValue({
ok: false,
status: 400,
statusText: 'Bad Request'
});
await expect(sdk.track('test_event')).rejects.toThrow('HTTP 400: Bad Request');
});
it('should handle rate limiting (429)', async () => {
mockFetch.mockResolvedValue({
ok: false,
status: 429,
statusText: 'Too Many Requests'
});
await expect(sdk.track('test_event')).rejects.toThrow('HTTP 429: Too Many Requests');
});
it('should log error in debug mode when websiteId is missing', async () => {
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
const debugSdkNoWebsiteId = new MinimalTinyTapAnalytics({
apiKey: 'test-key',
debug: true
});
await expect(debugSdkNoWebsiteId.track('test_event')).rejects.toThrow(
'TinyTapAnalytics: websiteId is required but not configured'
);
expect(consoleErrorSpy).toHaveBeenCalledWith(
'TinyTapAnalytics: websiteId is required but not configured'
);
consoleErrorSpy.mockRestore();
});
});
describe('Integration with backend', () => {
beforeEach(() => {
sdk = new MinimalTinyTapAnalytics(mockConfig);
});
it('should send payload matching EventRequest model', async () => {
await sdk.track('page_view');
const callArgs = mockFetch.mock.calls[0];
const body = JSON.parse(callArgs[1].body);
// Verify all required fields from EventRequest model
expect(body).toHaveProperty('website_id');
expect(body).toHaveProperty('event_type');
expect(body).toHaveProperty('session_id');
expect(body).toHaveProperty('timestamp');
expect(body).toHaveProperty('user_agent');
expect(body).toHaveProperty('page_url');
expect(body).toHaveProperty('metadata');
// Verify timestamp is ISO 8601 format
expect(body.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
});
it('should accept 202 Accepted response from backend', async () => {
mockFetch.mockResolvedValue({
ok: true,
status: 202,
statusText: 'Accepted'
});
await expect(sdk.track('test_event')).resolves.not.toThrow();
});
});
describe('Auto-initialization', () => {
let originalNodeEnv: string | undefined;
let consoleLogSpy: jest.SpyInstance;
let consoleErrorSpy: jest.SpyInstance;
beforeEach(() => {
// Save original NODE_ENV
originalNodeEnv = process.env.NODE_ENV;
// Clear window.TinyTapAnalytics
delete (window as any).TinyTapAnalytics;
delete (window as any).__tinytapanalytics_config;
// Mock console methods
consoleLogSpy = jest.spyOn(console, 'log').mockImplementation();
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
// Clear module cache to allow re-import
jest.resetModules();
mockFetch.mockClear();
mockFetch.mockResolvedValue({
ok: true,
status: 202,
statusText: 'Accepted'
});
});
afterEach(() => {
// Restore NODE_ENV
process.env.NODE_ENV = originalNodeEnv;
consoleLogSpy.mockRestore();
consoleErrorSpy.mockRestore();
delete (window as any).TinyTapAnalytics;
delete (window as any).__tinytapanalytics_config;
});
it('should auto-initialize with script tag data attributes', () => {
// Set NODE_ENV to non-test value to trigger auto-init
process.env.NODE_ENV = 'production';
// Set debug mode in global config to trigger logging
(window as any).__tinytapanalytics_config = {
debug: true
};
// Mock script tag with data attributes
const mockScriptTag = {
dataset: {
apiKey: 'script-api-key',
websiteId: 'script-website-id'
}
};
Object.defineProperty(document, 'currentScript', {
value: mockScriptTag,
configurable: true
});
Object.defineProperty(document, 'readyState', {
value: 'complete',
configurable: true
});
// Re-import module to trigger auto-init
jest.isolateModules(() => {
require('../minimal');
});
// Verify SDK was attached to window
expect((window as any).TinyTapAnalytics).toBeDefined();
// Verify initialization was logged (debug mode enables logging)
expect(consoleLogSpy).toHaveBeenCalledWith(
'TinyTapAnalytics Minimal SDK initialized',
expect.objectContaining({
websiteId: 'script-website-id',
apiKey: '***',
debug: true
})
);
});
it('should auto-initialize with global config object', () => {
process.env.NODE_ENV = 'production';
// Set global config
(window as any).__tinytapanalytics_config = {
apiKey: 'global-api-key',
websiteId: 'global-website-id',
debug: true
};
Object.defineProperty(document, 'currentScript', {
value: null,
configurable: true
});
Object.defineProperty(document, 'readyState', {
value: 'complete',
configurable: true
});
jest.isolateModules(() => {
require('../minimal');
});
expect((window as any).TinyTapAnalytics).toBeDefined();
expect(consoleLogSpy).toHaveBeenCalledWith(
'TinyTapAnalytics Minimal SDK initialized',
expect.objectContaining({
websiteId: 'global-website-id',
debug: true
})
);
});
it('should log warning when websiteId is not set', () => {
process.env.NODE_ENV = 'production';
(window as any).__tinytapanalytics_config = {
apiKey: 'test-key'
// websiteId not provided
};
Object.defineProperty(document, 'currentScript', {
value: null,
configurable: true
});
Object.defineProperty(document, 'readyState', {
value: 'complete',
configurable: true
});
jest.isolateModules(() => {
require('../minimal');
});
expect(consoleLogSpy).toHaveBeenCalledWith(
'TinyTapAnalytics Minimal SDK initialized',
expect.objectContaining({
websiteId: 'NOT SET - Events will fail!'
})
);
});
it('should auto-track page view after initialization when DOM is ready', async () => {
process.env.NODE_ENV = 'production';
(window as any).__tinytapanalytics_config = {
apiKey: 'test-key',
websiteId: 'test-website-id'
};
Object.defineProperty(document, 'currentScript', {
value: null,
configurable: true
});
Object.defineProperty(document, 'readyState', {
value: 'complete',
configurable: true
});
mockFetch.mockClear();
jest.isolateModules(() => {
require('../minimal');
});
// Wait for async trackPageView to complete
await new Promise(resolve => setTimeout(resolve, 100));
// Verify page view was tracked
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining('/api/v1/events'),
expect.objectContaining({
method: 'POST'
})
);
const callArgs = mockFetch.mock.calls[0];
const body = JSON.parse(callArgs[1].body);
expect(body.event_type).toBe('page_view');
});
it('should handle page view tracking errors gracefully', async () => {
process.env.NODE_ENV = 'production';
(window as any).__tinytapanalytics_config = {
apiKey: 'test-key',
websiteId: 'test-website-id'
};
Object.defineProperty(document, 'currentScript', {
value: null,
configurable: true
});
Object.defineProperty(document, 'readyState', {
value: 'complete',
configurable: true
});
// Make fetch fail
mockFetch.mockRejectedValue(new Error('Network error'));
jest.isolateModules(() => {
require('../minimal');
});
// Wait for async trackPageView to fail
await new Promise(resolve => setTimeout(resolve, 100));
// Verify error was logged but didn't crash
expect(consoleErrorSpy).toHaveBeenCalledWith(
'TinyTapAnalytics: Failed to track initial page view',
expect.any(Error)
);
});
it('should wait for DOMContentLoaded if document is still loading', () => {
process.env.NODE_ENV = 'production';
(window as any).__tinytapanalytics_config = {
apiKey: 'test-key',
websiteId: 'test-website-id'
};
Object.defineProperty(document, 'currentScript', {
value: null,
configurable: true
});
Object.defineProperty(document, 'readyState', {
value: 'loading',
configurable: true
});
const addEventListenerSpy = jest.spyOn(document, 'addEventListener');
jest.isolateModules(() => {
require('../minimal');
});
// Verify DOMContentLoaded listener was added
expect(addEventListenerSpy).toHaveBeenCalledWith(
'DOMContentLoaded',
expect.any(Function)
);
addEventListenerSpy.mockRestore();
});
it('should use script tag apiKey as websiteId if websiteId not provided', () => {
process.env.NODE_ENV = 'production';
// Enable debug mode to see the logging
(window as any).__tinytapanalytics_config = {
debug: true
};
const mockScriptTag = {
dataset: {
apiKey: 'only-api-key'
// websiteId not provided
}
};
Object.defineProperty(document, 'currentScript', {
value: mockScriptTag,
configurable: true
});
Object.defineProperty(document, 'readyState', {
value: 'complete',
configurable: true
});
jest.isolateModules(() => {
require('../minimal');
});
expect(consoleLogSpy).toHaveBeenCalledWith(
'TinyTapAnalytics Minimal SDK initialized',
expect.objectContaining({
websiteId: 'only-api-key', // apiKey used as fallback
debug: true
})
);
});
it('should not auto-initialize without script tag or global config', () => {
process.env.NODE_ENV = 'production';
Object.defineProperty(document, 'currentScript', {
value: null,
configurable: true
});
// No global config set
jest.isolateModules(() => {
require('../minimal');
});
// SDK should not be attached to window
expect((window as any).TinyTapAnalytics).toBeUndefined();
});
});
});