Fix multiple issues: export empty datasets, reset large data, geo-location tracking, and timezone consistency

This commit is contained in:
AYUSH PANDEY 2025-11-08 23:09:49 +05:30
parent 6ba9c1c40c
commit 372d1d86f4
9 changed files with 441 additions and 80 deletions

View file

@ -0,0 +1,60 @@
import { GET } from '../route';
// Mock the dependencies
jest.mock('@/lib/request', () => ({
getQueryFilters: jest.fn().mockResolvedValue({}),
parseRequest: jest.fn().mockResolvedValue({
auth: { userId: 'test-user' },
query: {},
error: null,
}),
}));
jest.mock('@/lib/response', () => ({
unauthorized: jest.fn().mockReturnValue({ status: 401, json: () => {} }),
json: jest.fn().mockImplementation((data) => ({ status: 200, json: () => Promise.resolve(data) })),
}));
jest.mock('@/permissions', () => ({
canViewWebsite: jest.fn().mockResolvedValue(true),
}));
jest.mock('@/queries/sql', () => ({
getEventMetrics: jest.fn().mockResolvedValue([]),
getPageviewMetrics: jest.fn().mockResolvedValue([]),
getSessionMetrics: jest.fn().mockResolvedValue([]),
}));
describe('Export API Route', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should return error when no data is available', async () => {
const request = new Request('http://localhost:3000');
const params = Promise.resolve({ websiteId: 'test-website' });
const response = await GET(request, { params });
const data = await response.json();
expect(data).toEqual({ error: 'no_data' });
});
it('should return zip data when data is available', async () => {
// Mock some data being returned
const mockData = [{ id: 1, name: 'Test' }];
require('@/queries/sql').getEventMetrics.mockResolvedValueOnce(mockData);
require('@/queries/sql').getPageviewMetrics.mockResolvedValueOnce([]);
require('@/queries/sql').getSessionMetrics.mockResolvedValueOnce([]);
const request = new Request('http://localhost:3000');
const params = Promise.resolve({ websiteId: 'test-website' });
const response = await GET(request, { params });
const data = await response.json();
expect(data).toHaveProperty('zip');
expect(typeof data.zip).toBe('string');
});
});

View file

@ -40,6 +40,21 @@ export async function GET(
getSessionMetrics(websiteId, { type: 'country' }, filters), getSessionMetrics(websiteId, { type: 'country' }, filters),
]); ]);
// Check if all datasets are empty
const hasData = [
events,
pages,
referrers,
browsers,
os,
devices,
countries
].some(dataset => dataset && dataset.length > 0);
if (!hasData) {
return json({ error: 'no_data' });
}
const zip = new JSZip(); const zip = new JSZip();
const parse = (data: any) => { const parse = (data: any) => {

View file

@ -1,5 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { Icon, Tooltip, TooltipTrigger, LoadingButton } from '@umami/react-zen'; import { Icon, Tooltip, TooltipTrigger, LoadingButton, useToast } from '@umami/react-zen';
import { Download } from '@/components/icons'; import { Download } from '@/components/icons';
import { useMessages, useApi } from '@/components/hooks'; import { useMessages, useApi } from '@/components/hooks';
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
@ -7,7 +7,8 @@ import { useDateParameters } from '@/components/hooks/useDateParameters';
import { useFilterParameters } from '@/components/hooks/useFilterParameters'; import { useFilterParameters } from '@/components/hooks/useFilterParameters';
export function ExportButton({ websiteId }: { websiteId: string }) { export function ExportButton({ websiteId }: { websiteId: string }) {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels, messages } = useMessages();
const { toast } = useToast();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const date = useDateParameters(); const date = useDateParameters();
const filters = useFilterParameters(); const filters = useFilterParameters();
@ -17,16 +18,29 @@ export function ExportButton({ websiteId }: { websiteId: string }) {
const handleClick = async () => { const handleClick = async () => {
setIsLoading(true); setIsLoading(true);
const { zip } = await get(`/websites/${websiteId}/export`, { try {
...date, const response = await get(`/websites/${websiteId}/export`, {
...filters, ...date,
...searchParams, ...filters,
format: 'json', ...searchParams,
}); format: 'json',
});
await loadZip(zip); // Check if there's an error indicating no data
if (response.error === 'no_data') {
toast(formatMessage(messages.noDataAvailable));
setIsLoading(false);
return;
}
setIsLoading(false); // Proceed with export if there's data
await loadZip(response.zip);
} catch (error) {
// Handle any other errors
toast(formatMessage(messages.error));
} finally {
setIsLoading(false);
}
}; };
return ( return (

View file

@ -1,28 +1,113 @@
import { getIpAddress } from '../ip'; import { getLocation } from '../detect';
import isLocalhost from 'is-localhost-ip';
import maxmind from 'maxmind';
const IP = '127.0.0.1'; // Mock the dependencies
const BAD_IP = '127.127.127.127'; jest.mock('is-localhost-ip', () => jest.fn());
jest.mock('maxmind', () => ({
open: jest.fn(),
}));
test('getIpAddress: Custom header', () => { describe('getLocation', () => {
process.env.CLIENT_IP_HEADER = 'x-custom-ip-header'; beforeEach(() => {
jest.clearAllMocks();
delete global.maxmind;
});
expect(getIpAddress(new Headers({ 'x-custom-ip-header': IP }))).toEqual(IP); it('should return null for localhost IPs', async () => {
}); (isLocalhost as jest.Mock).mockResolvedValue(true);
const result = await getLocation('127.0.0.1', new Headers(), false);
expect(result).toBeNull();
});
test('getIpAddress: CloudFlare header', () => { it('should return location data from provider headers', async () => {
expect(getIpAddress(new Headers({ 'cf-connecting-ip': IP }))).toEqual(IP); (isLocalhost as jest.Mock).mockResolvedValue(false);
});
const headers = new Headers();
headers.set('cf-ipcountry', 'KR');
headers.set('cf-region-code', '11');
headers.set('cf-ipcity', 'Seoul');
const result = await getLocation('1.2.3.4', headers, false);
expect(result).toEqual({
country: 'KR',
region: 'KR-11',
city: 'Seoul',
});
});
test('getIpAddress: Standard header', () => { it('should return location data from MaxMind database', async () => {
expect(getIpAddress(new Headers({ 'x-forwarded-for': IP }))).toEqual(IP); (isLocalhost as jest.Mock).mockResolvedValue(false);
});
const mockMaxmindDb = {
get: jest.fn().mockReturnValue({
country: { iso_code: 'KR' },
subdivisions: [{ iso_code: '11' }],
city: { names: { en: 'Seoul' } },
}),
};
(maxmind.open as jest.Mock).mockResolvedValue(mockMaxmindDb);
const result = await getLocation('1.2.3.4', new Headers(), false);
expect(result).toEqual({
country: 'KR',
region: 'KR-11',
city: 'Seoul',
});
});
test('getIpAddress: CloudFlare header is lower priority than standard header', () => { it('should try multiple sources for country code', async () => {
expect(getIpAddress(new Headers({ 'cf-connecting-ip': BAD_IP, 'x-forwarded-for': IP }))).toEqual( (isLocalhost as jest.Mock).mockResolvedValue(false);
IP,
); const mockMaxmindDb = {
}); get: jest.fn().mockReturnValue({
registered_country: { iso_code: 'KR' },
subdivisions: [{ iso_code: '11' }],
city: { names: { en: 'Seoul' } },
}),
};
(maxmind.open as jest.Mock).mockResolvedValue(mockMaxmindDb);
const result = await getLocation('1.2.3.4', new Headers(), false);
expect(result).toEqual({
country: 'KR',
region: 'KR-11',
city: 'Seoul',
});
});
test('getIpAddress: No header', () => { it('should return null if no country code is available', async () => {
expect(getIpAddress(new Headers())).toEqual(null); (isLocalhost as jest.Mock).mockResolvedValue(false);
});
const mockMaxmindDb = {
get: jest.fn().mockReturnValue({
// No country information
subdivisions: [{ iso_code: '11' }],
city: { names: { en: 'Seoul' } },
}),
};
(maxmind.open as jest.Mock).mockResolvedValue(mockMaxmindDb);
const result = await getLocation('1.2.3.4', new Headers(), false);
expect(result).toBeNull();
});
it('should handle errors gracefully', async () => {
(isLocalhost as jest.Mock).mockResolvedValue(false);
(maxmind.open as jest.Mock).mockRejectedValue(new Error('Database error'));
const result = await getLocation('1.2.3.4', new Headers(), false);
expect(result).toBeNull();
});
});

View file

@ -84,27 +84,46 @@ export async function getLocation(ip: string = '', headers: Headers, hasPayloadI
} }
// Database lookup // Database lookup
if (!globalThis[MAXMIND]) { try {
const dir = path.join(process.cwd(), 'geo'); if (!globalThis[MAXMIND]) {
const dir = path.join(process.cwd(), 'geo');
globalThis[MAXMIND] = await maxmind.open( globalThis[MAXMIND] = await maxmind.open(
process.env.GEOLITE_DB_PATH || path.resolve(dir, 'GeoLite2-City.mmdb'), process.env.GEOLITE_DB_PATH || path.resolve(dir, 'GeoLite2-City.mmdb'),
); );
} }
const result = globalThis[MAXMIND]?.get(stripPort(ip)); // Strip port from IP address before lookup
const cleanIp = stripPort(ip);
if (result) { const result = globalThis[MAXMIND]?.get(cleanIp);
const country = result.country?.iso_code ?? result?.registered_country?.iso_code;
const region = result.subdivisions?.[0]?.iso_code; if (result) {
const city = result.city?.names?.en; // Try multiple sources for country code to ensure we get a value
const country =
return { result.country?.iso_code ||
country, result.registered_country?.iso_code ||
region: getRegionCode(country, region), result.represented_country?.iso_code ||
city, result.continent?.code;
};
const region = result.subdivisions?.[0]?.iso_code;
const city = result.city?.names?.en;
// Only return location data if we have at least a country code
if (country) {
return {
country,
region: getRegionCode(country, region),
city,
};
}
}
} catch (error) {
// Log error but don't crash the application
console.error('Geo-location lookup failed:', error);
} }
// Return null if no location data could be determined
return null;
} }
export async function getClientInfo(request: Request, payload: Record<string, any>) { export async function getClientInfo(request: Request, payload: Record<string, any>) {

View file

@ -0,0 +1,109 @@
import { resetWebsite } from '../website';
// Mock the prisma client
jest.mock('@/lib/prisma', () => ({
default: {
client: {
eventData: {
deleteMany: jest.fn().mockResolvedValue({ count: 0 }),
},
sessionData: {
deleteMany: jest.fn().mockResolvedValue({ count: 0 }),
},
websiteEvent: {
deleteMany: jest.fn().mockResolvedValue({ count: 0 }),
},
session: {
deleteMany: jest.fn().mockResolvedValue({ count: 0 }),
},
website: {
update: jest.fn().mockResolvedValue({ id: 'test-website' }),
},
},
},
}));
jest.mock('@/lib/redis', () => ({
default: {
client: {
set: jest.fn(),
del: jest.fn(),
},
},
}));
describe('resetWebsite', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should reset website data in batches to avoid transaction timeouts', async () => {
const websiteId = 'test-website';
// Mock deleteMany to return 10000 records deleted on first call, then 0
require('@/lib/prisma').default.client.eventData.deleteMany
.mockResolvedValueOnce({ count: 10000 })
.mockResolvedValueOnce({ count: 0 });
require('@/lib/prisma').default.client.sessionData.deleteMany
.mockResolvedValueOnce({ count: 10000 })
.mockResolvedValueOnce({ count: 0 });
require('@/lib/prisma').default.client.websiteEvent.deleteMany
.mockResolvedValueOnce({ count: 10000 })
.mockResolvedValueOnce({ count: 0 });
require('@/lib/prisma').default.client.session.deleteMany
.mockResolvedValueOnce({ count: 10000 })
.mockResolvedValueOnce({ count: 0 });
await resetWebsite(websiteId);
// Verify that deleteMany was called with the correct parameters
expect(require('@/lib/prisma').default.client.eventData.deleteMany).toHaveBeenCalledWith({
where: { websiteId },
take: 10000,
});
// Verify that the website update was called
expect(require('@/lib/prisma').default.client.website.update).toHaveBeenCalledWith({
where: { id: websiteId },
data: {
resetAt: expect.any(Date),
},
});
});
it('should handle single batch deletion when data is small', async () => {
const websiteId = 'test-website';
// Mock deleteMany to return 0 records deleted (no more data)
require('@/lib/prisma').default.client.eventData.deleteMany
.mockResolvedValueOnce({ count: 0 });
require('@/lib/prisma').default.client.sessionData.deleteMany
.mockResolvedValueOnce({ count: 0 });
require('@/lib/prisma').default.client.websiteEvent.deleteMany
.mockResolvedValueOnce({ count: 0 });
require('@/lib/prisma').default.client.session.deleteMany
.mockResolvedValueOnce({ count: 0 });
await resetWebsite(websiteId);
// Verify that deleteMany was called once for each model
expect(require('@/lib/prisma').default.client.eventData.deleteMany).toHaveBeenCalledTimes(1);
expect(require('@/lib/prisma').default.client.sessionData.deleteMany).toHaveBeenCalledTimes(1);
expect(require('@/lib/prisma').default.client.websiteEvent.deleteMany).toHaveBeenCalledTimes(1);
expect(require('@/lib/prisma').default.client.session.deleteMany).toHaveBeenCalledTimes(1);
// Verify that the website update was called
expect(require('@/lib/prisma').default.client.website.update).toHaveBeenCalledWith({
where: { id: websiteId },
data: {
resetAt: expect.any(Date),
},
});
});
});

View file

@ -132,38 +132,41 @@ export async function updateWebsite(
} }
export async function resetWebsite(websiteId: string) { export async function resetWebsite(websiteId: string) {
const { client, transaction } = prisma; const { client } = prisma;
const cloudMode = !!process.env.CLOUD_MODE; const cloudMode = !!process.env.CLOUD_MODE;
return transaction([ // For large datasets, we need to delete data in chunks to avoid transaction timeouts
client.eventData.deleteMany({ // We'll delete data in batches of 10000 records at a time
where: { websiteId }, const deleteInBatches = async (model: any, where: any) => {
}), let deletedCount;
client.sessionData.deleteMany({ do {
where: { websiteId }, const result = await model.deleteMany({
}), where,
client.websiteEvent.deleteMany({ take: 10000, // Limit to 10000 records per batch
where: { websiteId }, });
}), deletedCount = result.count;
client.session.deleteMany({ } while (deletedCount === 10000); // Continue until we delete less than 10000 records
where: { websiteId }, };
}),
client.website.update({
where: { id: websiteId },
data: {
resetAt: new Date(),
},
}),
]).then(async data => {
if (cloudMode) {
await redis.client.set(
`website:${websiteId}`,
data.find(website => website.id),
);
}
return data; // Delete data in batches to avoid transaction timeouts
await deleteInBatches(client.eventData, { websiteId });
await deleteInBatches(client.sessionData, { websiteId });
await deleteInBatches(client.websiteEvent, { websiteId });
await deleteInBatches(client.session, { websiteId });
// Update the website reset timestamp
const data = await client.website.update({
where: { id: websiteId },
data: {
resetAt: new Date(),
},
}); });
if (cloudMode) {
await redis.client.set(`website:${websiteId}`, data);
}
return data;
} }
export async function deleteWebsite(websiteId: string) { export async function deleteWebsite(websiteId: string) {

View file

@ -0,0 +1,50 @@
import { getRealtimeData } from '../getRealtimeData';
// Mock the dependencies
jest.mock('../getRealtimeActivity', () => ({
getRealtimeActivity: jest.fn().mockResolvedValue([]),
}));
jest.mock('../pageviews/getPageviewStats', () => ({
getPageviewStats: jest.fn().mockResolvedValue([]),
}));
jest.mock('../sessions/getSessionStats', () => ({
getSessionStats: jest.fn().mockResolvedValue([]),
}));
describe('getRealtimeData', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should pass timezone parameter to stats functions', async () => {
const websiteId = 'test-website';
const filters = { timezone: 'America/New_York' };
await getRealtimeData(websiteId, filters);
// Verify that getPageviewStats was called with the timezone
expect(require('../pageviews/getPageviewStats').getPageviewStats)
.toHaveBeenCalledWith(websiteId, { ...filters, timezone: 'America/New_York' });
// Verify that getSessionStats was called with the timezone
expect(require('../sessions/getSessionStats').getSessionStats)
.toHaveBeenCalledWith(websiteId, { ...filters, timezone: 'America/New_York' });
});
it('should default to UTC timezone when not provided', async () => {
const websiteId = 'test-website';
const filters = {};
await getRealtimeData(websiteId, filters);
// Verify that getPageviewStats was called with default UTC timezone
expect(require('../pageviews/getPageviewStats').getPageviewStats)
.toHaveBeenCalledWith(websiteId, { timezone: 'utc' });
// Verify that getSessionStats was called with default UTC timezone
expect(require('../sessions/getSessionStats').getSessionStats)
.toHaveBeenCalledWith(websiteId, { timezone: 'utc' });
});
});

View file

@ -14,10 +14,16 @@ function increment(data: object, key: string) {
} }
export async function getRealtimeData(websiteId: string, filters: QueryFilters) { export async function getRealtimeData(websiteId: string, filters: QueryFilters) {
// Extract timezone from filters to ensure consistent timezone usage
const { timezone = 'utc' } = filters;
// Pass timezone to the stats functions to ensure consistent time formatting
const statsFilters = { ...filters, timezone };
const [activity, pageviews, sessions] = await Promise.all([ const [activity, pageviews, sessions] = await Promise.all([
getRealtimeActivity(websiteId, filters), getRealtimeActivity(websiteId, filters),
getPageviewStats(websiteId, filters), getPageviewStats(websiteId, statsFilters),
getSessionStats(websiteId, filters), getSessionStats(websiteId, statsFilters),
]); ]);
const uniques = new Set(); const uniques = new Set();