diff --git a/src/app/api/websites/[websiteId]/export/__tests__/route.test.ts b/src/app/api/websites/[websiteId]/export/__tests__/route.test.ts new file mode 100644 index 00000000..3d9c7886 --- /dev/null +++ b/src/app/api/websites/[websiteId]/export/__tests__/route.test.ts @@ -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'); + }); +}); \ No newline at end of file diff --git a/src/app/api/websites/[websiteId]/export/route.ts b/src/app/api/websites/[websiteId]/export/route.ts index fbf250e6..7322b974 100644 --- a/src/app/api/websites/[websiteId]/export/route.ts +++ b/src/app/api/websites/[websiteId]/export/route.ts @@ -40,6 +40,21 @@ export async function GET( 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 parse = (data: any) => { diff --git a/src/components/input/ExportButton.tsx b/src/components/input/ExportButton.tsx index 15fb2124..18ebfdf2 100644 --- a/src/components/input/ExportButton.tsx +++ b/src/components/input/ExportButton.tsx @@ -1,5 +1,5 @@ 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 { useMessages, useApi } from '@/components/hooks'; import { useSearchParams } from 'next/navigation'; @@ -7,7 +7,8 @@ import { useDateParameters } from '@/components/hooks/useDateParameters'; import { useFilterParameters } from '@/components/hooks/useFilterParameters'; export function ExportButton({ websiteId }: { websiteId: string }) { - const { formatMessage, labels } = useMessages(); + const { formatMessage, labels, messages } = useMessages(); + const { toast } = useToast(); const [isLoading, setIsLoading] = useState(false); const date = useDateParameters(); const filters = useFilterParameters(); @@ -17,16 +18,29 @@ export function ExportButton({ websiteId }: { websiteId: string }) { const handleClick = async () => { setIsLoading(true); - const { zip } = await get(`/websites/${websiteId}/export`, { - ...date, - ...filters, - ...searchParams, - format: 'json', - }); + try { + const response = await get(`/websites/${websiteId}/export`, { + ...date, + ...filters, + ...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 ( diff --git a/src/lib/__tests__/detect.test.ts b/src/lib/__tests__/detect.test.ts index f02ac839..27343df2 100644 --- a/src/lib/__tests__/detect.test.ts +++ b/src/lib/__tests__/detect.test.ts @@ -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'; -const BAD_IP = '127.127.127.127'; +// Mock the dependencies +jest.mock('is-localhost-ip', () => jest.fn()); +jest.mock('maxmind', () => ({ + open: jest.fn(), +})); -test('getIpAddress: Custom header', () => { - process.env.CLIENT_IP_HEADER = 'x-custom-ip-header'; +describe('getLocation', () => { + 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', () => { - expect(getIpAddress(new Headers({ 'cf-connecting-ip': IP }))).toEqual(IP); -}); + it('should return location data from provider headers', async () => { + (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', () => { - expect(getIpAddress(new Headers({ 'x-forwarded-for': IP }))).toEqual(IP); -}); + it('should return location data from MaxMind database', async () => { + (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', () => { - expect(getIpAddress(new Headers({ 'cf-connecting-ip': BAD_IP, 'x-forwarded-for': IP }))).toEqual( - IP, - ); -}); + it('should try multiple sources for country code', async () => { + (isLocalhost as jest.Mock).mockResolvedValue(false); + + 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', () => { - expect(getIpAddress(new Headers())).toEqual(null); -}); + it('should return null if no country code is available', async () => { + (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(); + }); +}); \ No newline at end of file diff --git a/src/lib/detect.ts b/src/lib/detect.ts index c5528465..c8ef5a3b 100644 --- a/src/lib/detect.ts +++ b/src/lib/detect.ts @@ -84,27 +84,46 @@ export async function getLocation(ip: string = '', headers: Headers, hasPayloadI } // Database lookup - if (!globalThis[MAXMIND]) { - const dir = path.join(process.cwd(), 'geo'); + try { + if (!globalThis[MAXMIND]) { + const dir = path.join(process.cwd(), 'geo'); - globalThis[MAXMIND] = await maxmind.open( - process.env.GEOLITE_DB_PATH || path.resolve(dir, 'GeoLite2-City.mmdb'), - ); - } - - const result = globalThis[MAXMIND]?.get(stripPort(ip)); - - if (result) { - const country = result.country?.iso_code ?? result?.registered_country?.iso_code; - const region = result.subdivisions?.[0]?.iso_code; - const city = result.city?.names?.en; - - return { - country, - region: getRegionCode(country, region), - city, - }; + globalThis[MAXMIND] = await maxmind.open( + process.env.GEOLITE_DB_PATH || path.resolve(dir, 'GeoLite2-City.mmdb'), + ); + } + + // Strip port from IP address before lookup + const cleanIp = stripPort(ip); + const result = globalThis[MAXMIND]?.get(cleanIp); + + if (result) { + // Try multiple sources for country code to ensure we get a value + const country = + result.country?.iso_code || + result.registered_country?.iso_code || + result.represented_country?.iso_code || + 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) { diff --git a/src/queries/prisma/__tests__/website.test.ts b/src/queries/prisma/__tests__/website.test.ts new file mode 100644 index 00000000..c21ee5ab --- /dev/null +++ b/src/queries/prisma/__tests__/website.test.ts @@ -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), + }, + }); + }); +}); \ No newline at end of file diff --git a/src/queries/prisma/website.ts b/src/queries/prisma/website.ts index 5f5404d5..3aaac16a 100644 --- a/src/queries/prisma/website.ts +++ b/src/queries/prisma/website.ts @@ -132,38 +132,41 @@ export async function updateWebsite( } export async function resetWebsite(websiteId: string) { - const { client, transaction } = prisma; + const { client } = prisma; const cloudMode = !!process.env.CLOUD_MODE; - return transaction([ - client.eventData.deleteMany({ - where: { websiteId }, - }), - client.sessionData.deleteMany({ - where: { websiteId }, - }), - client.websiteEvent.deleteMany({ - where: { websiteId }, - }), - client.session.deleteMany({ - 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), - ); - } + // For large datasets, we need to delete data in chunks to avoid transaction timeouts + // We'll delete data in batches of 10000 records at a time + const deleteInBatches = async (model: any, where: any) => { + let deletedCount; + do { + const result = await model.deleteMany({ + where, + take: 10000, // Limit to 10000 records per batch + }); + deletedCount = result.count; + } while (deletedCount === 10000); // Continue until we delete less than 10000 records + }; - 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) { diff --git a/src/queries/sql/__tests__/getRealtimeData.test.ts b/src/queries/sql/__tests__/getRealtimeData.test.ts new file mode 100644 index 00000000..9e6d1a40 --- /dev/null +++ b/src/queries/sql/__tests__/getRealtimeData.test.ts @@ -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' }); + }); +}); \ No newline at end of file diff --git a/src/queries/sql/getRealtimeData.ts b/src/queries/sql/getRealtimeData.ts index b004c420..8c610fda 100644 --- a/src/queries/sql/getRealtimeData.ts +++ b/src/queries/sql/getRealtimeData.ts @@ -14,10 +14,16 @@ function increment(data: object, key: string) { } 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([ getRealtimeActivity(websiteId, filters), - getPageviewStats(websiteId, filters), - getSessionStats(websiteId, filters), + getPageviewStats(websiteId, statsFilters), + getSessionStats(websiteId, statsFilters), ]); const uniques = new Set();