diff --git a/src/app/api/send/route.test.ts b/src/app/api/send/route.test.ts new file mode 100644 index 000000000..b9971904d --- /dev/null +++ b/src/app/api/send/route.test.ts @@ -0,0 +1,673 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +// Set a deterministic secret before anything reads it so that real crypto/jwt +// helpers used by the route and by the test produce matching tokens. +process.env.APP_SECRET = 'route-send-test-secret'; + +import { isbot } from 'isbot'; +import clickhouse from '@/lib/clickhouse'; +import { CACHE_TOKEN_TYPE, EVENT_TYPE } from '@/lib/constants'; +import { secret } from '@/lib/crypto'; +import { getClientInfo, hasBlockedIp } from '@/lib/detect'; +import { createToken, parseToken } from '@/lib/jwt'; +import { fetchWebsite } from '@/lib/load'; +import { parseRequest } from '@/lib/request'; +import { + createSession, + saveEvent, + saveSessionData, + saveSessionLink, + updateSession, +} from '@/queries/sql'; +import { POST } from './route'; + +vi.mock('@/lib/clickhouse', () => ({ default: { enabled: false } })); + +vi.mock('@/lib/detect', () => ({ + getClientInfo: vi.fn(), + hasBlockedIp: vi.fn(), +})); + +vi.mock('@/lib/load', () => ({ + fetchWebsite: vi.fn(), +})); + +vi.mock('@/lib/request', () => ({ + parseRequest: vi.fn(), +})); + +vi.mock('@/queries/sql', () => ({ + createSession: vi.fn(), + saveEvent: vi.fn(), + saveSessionData: vi.fn(), + saveSessionLink: vi.fn(), + updateSession: vi.fn(), +})); + +vi.mock('isbot', () => ({ + isbot: vi.fn(), +})); + +const parseRequestMock = vi.mocked(parseRequest); +const getClientInfoMock = vi.mocked(getClientInfo); +const hasBlockedIpMock = vi.mocked(hasBlockedIp); +const fetchWebsiteMock = vi.mocked(fetchWebsite); +const isbotMock = vi.mocked(isbot); +const createSessionMock = vi.mocked(createSession); +const saveEventMock = vi.mocked(saveEvent); +const saveSessionDataMock = vi.mocked(saveSessionData); +const saveSessionLinkMock = vi.mocked(saveSessionLink); +const updateSessionMock = vi.mocked(updateSession); + +const WEBSITE_ID = '11111111-1111-4111-8111-111111111111'; +const LINK_ID = '22222222-2222-4222-8222-222222222222'; +const PIXEL_ID = '33333333-3333-4333-8333-333333333333'; + +const defaultClientInfo = { + ip: '203.0.113.5', + userAgent: 'Mozilla/5.0', + device: 'desktop', + browser: 'chrome', + os: 'Windows 10', + country: 'US', + region: 'US-CA', + city: 'San Francisco', +}; + +/** + * Drives POST by making parseRequest resolve to the given body. Because the + * route calls parseRequest(request, schema, ...), the schema is still captured + * on the mock for direct schema assertions. + */ +function callPOST( + { type, payload }: { type: string; payload: Record }, + { headers }: { headers?: Record } = {}, +) { + parseRequestMock.mockResolvedValue({ body: { type, payload }, error: undefined }); + + return POST( + new Request('http://localhost/api/send', { + method: 'POST', + headers, + }), + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + (clickhouse as any).enabled = false; + delete process.env.DISABLE_BOT_CHECK; + delete process.env.REMOVE_TRAILING_SLASH; + delete process.env.SALT_ROTATION; + + isbotMock.mockReturnValue(false); + hasBlockedIpMock.mockReturnValue(false); + fetchWebsiteMock.mockResolvedValue({ id: WEBSITE_ID } as any); + getClientInfoMock.mockResolvedValue({ ...defaultClientInfo } as any); + createSessionMock.mockResolvedValue(undefined as any); + saveEventMock.mockResolvedValue(undefined as any); + saveSessionDataMock.mockResolvedValue(undefined as any); + saveSessionLinkMock.mockResolvedValue(undefined as any); + updateSessionMock.mockResolvedValue(undefined as any); +}); + +describe('parseRequest error handling', () => { + test('returns the parseRequest error response and does not process the event', async () => { + parseRequestMock.mockResolvedValue({ + body: undefined, + error: () => Response.json({ error: { code: 'bad-request', status: 400 } }, { status: 400 }), + }); + + const response = await POST(new Request('http://localhost/api/send', { method: 'POST' })); + + expect(response.status).toBe(400); + expect(saveEventMock).not.toHaveBeenCalled(); + expect(getClientInfoMock).not.toHaveBeenCalled(); + }); + + test('calls parseRequest with skipAuth so tracker requests are unauthenticated', async () => { + await callPOST({ type: 'event', payload: { website: WEBSITE_ID, url: '/' } }); + + expect(parseRequestMock.mock.calls[0][2]).toEqual({ skipAuth: true }); + }); +}); + +describe('schema validation', () => { + // The schema is passed to parseRequest; grab it and exercise safeParse directly. + async function getSchema() { + await callPOST({ type: 'event', payload: { website: WEBSITE_ID, url: '/' } }); + return parseRequestMock.mock.calls[0][1] as { + safeParse: (value: unknown) => { success: boolean }; + }; + } + + test('rejects an invalid type enum value', async () => { + const schema = await getSchema(); + + expect(schema.safeParse({ type: 'bogus', payload: { website: WEBSITE_ID } }).success).toBe( + false, + ); + expect(schema.safeParse({ type: 'event', payload: { website: WEBSITE_ID } }).success).toBe( + true, + ); + expect(schema.safeParse({ type: 'identify', payload: { website: WEBSITE_ID } }).success).toBe( + true, + ); + expect( + schema.safeParse({ type: 'performance', payload: { website: WEBSITE_ID } }).success, + ).toBe(true); + }); + + test('requires exactly one of website, link, or pixel', async () => { + const schema = await getSchema(); + + // zero set -> invalid + expect(schema.safeParse({ type: 'event', payload: {} }).success).toBe(false); + // one set -> valid + expect(schema.safeParse({ type: 'event', payload: { website: WEBSITE_ID } }).success).toBe( + true, + ); + expect(schema.safeParse({ type: 'event', payload: { link: LINK_ID } }).success).toBe(true); + expect(schema.safeParse({ type: 'event', payload: { pixel: PIXEL_ID } }).success).toBe(true); + // two set -> invalid + expect( + schema.safeParse({ type: 'event', payload: { website: WEBSITE_ID, link: LINK_ID } }).success, + ).toBe(false); + // all three set -> invalid + expect( + schema.safeParse({ + type: 'event', + payload: { website: WEBSITE_ID, link: LINK_ID, pixel: PIXEL_ID }, + }).success, + ).toBe(false); + }); + + test('rejects CSV formula injection triggers in name and tag', async () => { + const schema = await getSchema(); + + for (const bad of ['=1+1', '+cmd', '-2', '@SUM', '\tvalue', '\rvalue']) { + expect( + schema.safeParse({ type: 'event', payload: { website: WEBSITE_ID, name: bad } }).success, + ).toBe(false); + expect( + schema.safeParse({ type: 'event', payload: { website: WEBSITE_ID, tag: bad } }).success, + ).toBe(false); + } + + // A safe leading character passes. + expect( + schema.safeParse({ type: 'event', payload: { website: WEBSITE_ID, name: 'signup' } }).success, + ).toBe(true); + }); + + test('enforces web vitals numeric bounds', async () => { + const schema = await getSchema(); + + expect( + schema.safeParse({ type: 'performance', payload: { website: WEBSITE_ID, lcp: -1 } }).success, + ).toBe(false); + expect( + schema.safeParse({ type: 'performance', payload: { website: WEBSITE_ID, lcp: 60001 } }) + .success, + ).toBe(false); + expect( + schema.safeParse({ type: 'performance', payload: { website: WEBSITE_ID, cls: 101 } }).success, + ).toBe(false); + expect( + schema.safeParse({ type: 'performance', payload: { website: WEBSITE_ID, lcp: 2500 } }) + .success, + ).toBe(true); + }); +}); + +describe('bot detection gate', () => { + test('returns a 200 no-op response for bots and skips persistence', async () => { + isbotMock.mockReturnValue(true); + + const response = await callPOST({ + type: 'event', + payload: { website: WEBSITE_ID, url: '/' }, + }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ beep: 'boop' }); + expect(saveEventMock).not.toHaveBeenCalled(); + }); + + test('DISABLE_BOT_CHECK bypasses the bot gate', async () => { + process.env.DISABLE_BOT_CHECK = '1'; + isbotMock.mockReturnValue(true); + + const response = await callPOST({ + type: 'event', + payload: { website: WEBSITE_ID, url: '/' }, + }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.not.toEqual({ beep: 'boop' }); + expect(saveEventMock).toHaveBeenCalledTimes(1); + expect(isbotMock).not.toHaveBeenCalled(); + }); +}); + +describe('IP blocklist', () => { + test('returns 403 when the client IP is blocked', async () => { + hasBlockedIpMock.mockReturnValue(true); + + const response = await callPOST({ + type: 'event', + payload: { website: WEBSITE_ID, url: '/' }, + }); + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toMatchObject({ + error: { code: 'forbidden', status: 403 }, + }); + expect(saveEventMock).not.toHaveBeenCalled(); + }); +}); + +describe('website lookup', () => { + test('returns 400 when the website does not exist', async () => { + fetchWebsiteMock.mockResolvedValue(null as any); + + const response = await callPOST({ + type: 'event', + payload: { website: WEBSITE_ID, url: '/' }, + }); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ + error: { message: 'Website not found.', status: 400 }, + }); + expect(saveEventMock).not.toHaveBeenCalled(); + }); + + test('does not look up a website for link events', async () => { + await callPOST({ type: 'event', payload: { link: LINK_ID, url: '/' } }); + + expect(fetchWebsiteMock).not.toHaveBeenCalled(); + }); +}); + +describe('session creation', () => { + test('creates a session when clickhouse is disabled and there is no cache', async () => { + await callPOST({ type: 'event', payload: { website: WEBSITE_ID, url: '/' } }); + + expect(createSessionMock).toHaveBeenCalledTimes(1); + expect(createSessionMock.mock.calls[0][0]).toMatchObject({ + websiteId: WEBSITE_ID, + browser: 'chrome', + os: 'Windows 10', + device: 'desktop', + }); + }); + + test('does not create a session when clickhouse is enabled', async () => { + (clickhouse as any).enabled = true; + + await callPOST({ type: 'event', payload: { website: WEBSITE_ID, url: '/' } }); + + expect(createSessionMock).not.toHaveBeenCalled(); + }); +}); + +describe('eventType selection ladder', () => { + test('link events use the linkEvent type', async () => { + await callPOST({ type: 'event', payload: { link: LINK_ID, url: '/' } }); + + expect(saveEventMock.mock.calls[0][0]).toMatchObject({ eventType: EVENT_TYPE.linkEvent }); + }); + + test('pixel events use the pixelEvent type', async () => { + await callPOST({ type: 'event', payload: { pixel: PIXEL_ID, url: '/' } }); + + expect(saveEventMock.mock.calls[0][0]).toMatchObject({ eventType: EVENT_TYPE.pixelEvent }); + }); + + test('named website events use the customEvent type', async () => { + await callPOST({ + type: 'event', + payload: { website: WEBSITE_ID, url: '/', name: 'signup' }, + }); + + expect(saveEventMock.mock.calls[0][0]).toMatchObject({ + eventType: EVENT_TYPE.customEvent, + eventName: 'signup', + }); + }); + + test('unnamed website events use the pageView type', async () => { + await callPOST({ type: 'event', payload: { website: WEBSITE_ID, url: '/' } }); + + expect(saveEventMock.mock.calls[0][0]).toMatchObject({ eventType: EVENT_TYPE.pageView }); + }); +}); + +describe('event url and referrer parsing', () => { + test('extracts url path, query, domain and referrer fields', async () => { + await callPOST({ + type: 'event', + payload: { + website: WEBSITE_ID, + hostname: 'example.com', + url: '/products?utm_source=news&gclid=abc', + referrer: 'https://www.google.com/search?q=umami', + }, + }); + + const arg = saveEventMock.mock.calls[0][0] as Record; + expect(arg).toMatchObject({ + hostname: 'example.com', + urlPath: '/products', + urlQuery: 'utm_source=news&gclid=abc', + utmSource: 'news', + gclid: 'abc', + referrerPath: '/search', + referrerQuery: 'q=umami', + referrerDomain: 'google.com', + }); + }); + + test('REMOVE_TRAILING_SLASH strips a trailing slash from the url path', async () => { + process.env.REMOVE_TRAILING_SLASH = '1'; + + await callPOST({ + type: 'event', + payload: { website: WEBSITE_ID, hostname: 'example.com', url: '/blog/' }, + }); + + expect(saveEventMock.mock.calls[0][0]).toMatchObject({ urlPath: '/blog' }); + }); + + test('maps a "/undefined" pathname to an empty url path', async () => { + await callPOST({ + type: 'event', + payload: { website: WEBSITE_ID, hostname: 'example.com', url: '/undefined' }, + }); + + expect(saveEventMock.mock.calls[0][0]).toMatchObject({ urlPath: '' }); + }); +}); + +describe('cache token handling', () => { + function makeCacheToken(overrides: Record = {}) { + return createToken( + { + type: CACHE_TOKEN_TYPE, + websiteId: WEBSITE_ID, + sessionId: 'cached-session', + visitId: 'cached-visit', + iat: Math.floor(Date.now() / 1000), + ...overrides, + }, + secret(), + ); + } + + test('a valid cache token skips website lookup and session creation', async () => { + const token = makeCacheToken(); + + const response = await callPOST( + { type: 'event', payload: { website: WEBSITE_ID, url: '/' } }, + { headers: { 'x-umami-cache': token } }, + ); + + expect(fetchWebsiteMock).not.toHaveBeenCalled(); + expect(createSessionMock).not.toHaveBeenCalled(); + await expect(response.json()).resolves.toMatchObject({ visitId: 'cached-visit' }); + }); + + test('an invalid cache token falls back to website lookup', async () => { + await callPOST( + { type: 'event', payload: { website: WEBSITE_ID, url: '/' } }, + { headers: { 'x-umami-cache': 'not-a-jwt' } }, + ); + + expect(fetchWebsiteMock).toHaveBeenCalledTimes(1); + }); + + test('a token with a non-cache type is ignored', async () => { + const token = createToken( + { type: 'share', websiteId: WEBSITE_ID, sessionId: 's', visitId: 'v', iat: 1 }, + secret(), + ); + + await callPOST( + { type: 'event', payload: { website: WEBSITE_ID, url: '/' } }, + { headers: { 'x-umami-cache': token } }, + ); + + expect(fetchWebsiteMock).toHaveBeenCalledTimes(1); + expect(createSessionMock).toHaveBeenCalledTimes(1); + }); + + test('an expired cache token is treated as absent', async () => { + const token = createToken( + { + type: CACHE_TOKEN_TYPE, + websiteId: WEBSITE_ID, + sessionId: 'cached-session', + visitId: 'cached-visit', + }, + secret(), + { expiresIn: -10 }, + ); + + const response = await callPOST( + { type: 'event', payload: { website: WEBSITE_ID, url: '/' } }, + { headers: { 'x-umami-cache': token } }, + ); + + expect(fetchWebsiteMock).toHaveBeenCalledTimes(1); + // A fresh visitId is generated rather than reusing the token's value. + await expect(response.json()).resolves.not.toMatchObject({ visitId: 'cached-visit' }); + }); + + test('returns a signed cache token that round-trips to the response identifiers', async () => { + const response = await callPOST({ + type: 'event', + payload: { website: WEBSITE_ID, url: '/' }, + }); + + const body = (await response.json()) as Record; + const decoded = parseToken(body.cache, secret()) as Record; + + expect(decoded.type).toBe(CACHE_TOKEN_TYPE); + expect(decoded.sessionId).toBe(body.sessionId); + expect(decoded.visitId).toBe(body.visitId); + expect(decoded.websiteId).toBe(WEBSITE_ID); + }); +}); + +describe('30-minute visit expiry', () => { + test('regenerates the visit when the cached iat is older than 30 minutes', async () => { + const oldIat = Math.floor(Date.now() / 1000) - 2000; + const token = createToken( + { + type: CACHE_TOKEN_TYPE, + websiteId: WEBSITE_ID, + sessionId: 'cached-session', + visitId: 'cached-visit', + iat: oldIat, + }, + secret(), + ); + + const response = await callPOST( + { type: 'event', payload: { website: WEBSITE_ID, url: '/' } }, + { headers: { 'x-umami-cache': token } }, + ); + + const body = (await response.json()) as Record; + expect(body.visitId).not.toBe('cached-visit'); + }); + + test('keeps the cached visit when within the 30-minute window', async () => { + const recentIat = Math.floor(Date.now() / 1000) - 100; + const token = createToken( + { + type: CACHE_TOKEN_TYPE, + websiteId: WEBSITE_ID, + sessionId: 'cached-session', + visitId: 'cached-visit', + iat: recentIat, + }, + secret(), + ); + + const response = await callPOST( + { type: 'event', payload: { website: WEBSITE_ID, url: '/' } }, + { headers: { 'x-umami-cache': token } }, + ); + + await expect(response.json()).resolves.toMatchObject({ visitId: 'cached-visit' }); + }); + + test('does not expire the visit when an explicit timestamp is supplied', async () => { + const oldIat = Math.floor(Date.now() / 1000) - 5000; + const token = createToken( + { + type: CACHE_TOKEN_TYPE, + websiteId: WEBSITE_ID, + sessionId: 'cached-session', + visitId: 'cached-visit', + iat: oldIat, + }, + secret(), + ); + + const response = await callPOST( + { + type: 'event', + payload: { website: WEBSITE_ID, url: '/', timestamp: 1000000000 }, + }, + { headers: { 'x-umami-cache': token } }, + ); + + await expect(response.json()).resolves.toMatchObject({ visitId: 'cached-visit' }); + }); +}); + +describe('identify collection', () => { + test('saves a session link and updates the session for a new distinctId', async () => { + await callPOST({ + type: 'identify', + payload: { website: WEBSITE_ID, id: 'user-42' }, + }); + + expect(saveSessionLinkMock).toHaveBeenCalledTimes(1); + expect(updateSessionMock).toHaveBeenCalledTimes(1); + expect(saveSessionLinkMock.mock.calls[0][0]).toMatchObject({ + websiteId: WEBSITE_ID, + distinctId: 'user-42', + }); + }); + + test('saves session data when a data payload is present', async () => { + await callPOST({ + type: 'identify', + payload: { website: WEBSITE_ID, id: 'user-42', data: { plan: 'pro' } }, + }); + + expect(saveSessionDataMock).toHaveBeenCalledTimes(1); + expect(saveSessionDataMock.mock.calls[0][0]).toMatchObject({ + websiteId: WEBSITE_ID, + sessionData: { plan: 'pro' }, + }); + }); + + test('skips identity writes when the cached sessionLinkId already matches', async () => { + // First identify establishes the link and returns a cache token carrying + // the resulting sessionLinkId. + const first = await callPOST({ + type: 'identify', + payload: { website: WEBSITE_ID, id: 'user-42' }, + }); + const firstBody = (await first.json()) as Record; + const token = firstBody.cache; + + saveSessionLinkMock.mockClear(); + updateSessionMock.mockClear(); + + // Replaying with that token should recognise the same identity and skip. + await callPOST( + { type: 'identify', payload: { website: WEBSITE_ID, id: 'user-42' } }, + { headers: { 'x-umami-cache': token } }, + ); + + expect(saveSessionLinkMock).not.toHaveBeenCalled(); + expect(updateSessionMock).not.toHaveBeenCalled(); + }); + + test('does not write identity records without a website id', async () => { + await callPOST({ + type: 'identify', + payload: { link: LINK_ID, id: 'user-42' }, + }); + + expect(saveSessionLinkMock).not.toHaveBeenCalled(); + expect(updateSessionMock).not.toHaveBeenCalled(); + }); + + test('identity link failures do not block session data writes', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + saveSessionLinkMock.mockRejectedValue(new Error('link failed')); + + const response = await callPOST({ + type: 'identify', + payload: { website: WEBSITE_ID, id: 'user-42', data: { plan: 'pro' } }, + }); + + expect(response.status).toBe(200); + expect(saveSessionDataMock).toHaveBeenCalledTimes(1); + consoleError.mockRestore(); + }); +}); + +describe('performance collection', () => { + test('saves a performance event with the web vitals metrics', async () => { + await callPOST({ + type: 'performance', + payload: { + website: WEBSITE_ID, + hostname: 'example.com', + url: '/dashboard', + lcp: 1200, + inp: 50, + cls: 0.05, + fcp: 900, + ttfb: 300, + }, + }); + + expect(saveEventMock).toHaveBeenCalledTimes(1); + expect(saveEventMock.mock.calls[0][0]).toMatchObject({ + eventType: EVENT_TYPE.performance, + urlPath: '/dashboard', + lcp: 1200, + inp: 50, + cls: 0.05, + fcp: 900, + ttfb: 300, + }); + }); +}); + +describe('error handling', () => { + test('returns a 500 server error when persistence throws', async () => { + saveEventMock.mockRejectedValue(new Error('db down')); + const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => {}); + + const response = await callPOST({ + type: 'event', + payload: { website: WEBSITE_ID, url: '/' }, + }); + + expect(response.status).toBe(500); + await expect(response.json()).resolves.toMatchObject({ + error: { code: 'server-error', status: 500 }, + }); + consoleLog.mockRestore(); + }); +}); diff --git a/src/components/hooks/useFields.test.ts b/src/components/hooks/useFields.test.ts new file mode 100644 index 000000000..b5d366b14 --- /dev/null +++ b/src/components/hooks/useFields.test.ts @@ -0,0 +1,83 @@ +import { renderHook } from '@testing-library/react'; +import { describe, expect, test, vi } from 'vitest'; + +// labels resolves any key to its own name; t is identity. +const labels = new Proxy({} as Record, { + get: (_target, prop: string) => prop, +}); + +vi.mock('./useMessages', () => ({ + useMessages: () => ({ + t: (value: string) => value, + labels, + }), +})); + +import { useFields } from './useFields'; + +function setup() { + return renderHook(() => useFields()).result.current; +} + +describe('useFields', () => { + test('exposes a field for every filterable column', () => { + const { fields } = setup(); + const names = fields.map(f => f.name); + + expect(names).toEqual([ + 'path', + 'query', + 'title', + 'referrer', + 'country', + 'region', + 'city', + 'browser', + 'os', + 'device', + 'utmSource', + 'utmMedium', + 'utmCampaign', + 'utmContent', + 'utmTerm', + 'hostname', + 'distinctId', + 'tag', + 'event', + ]); + }); + + test('assigns each field to the expected group', () => { + const { fields } = setup(); + const byName = Object.fromEntries(fields.map(f => [f.name, f.group])); + + expect(byName.path).toBe('url'); + expect(byName.referrer).toBe('sources'); + expect(byName.city).toBe('location'); + expect(byName.browser).toBe('environment'); + expect(byName.utmSource).toBe('utm'); + expect(byName.tag).toBe('other'); + }); + + test('uses distinct filterLabel and label where the source distinguishes them', () => { + const { fields } = setup(); + const utmSource = fields.find(f => f.name === 'utmSource'); + + // filterLabel uses the generic "source" label, label uses "utmSource". + expect(utmSource?.filterLabel).toBe('source'); + expect(utmSource?.label).toBe('utmSource'); + }); + + test('returns group labels in display order', () => { + const { groupLabels } = setup(); + + expect(groupLabels.map(g => g.key)).toEqual([ + 'url', + 'sources', + 'location', + 'environment', + 'utm', + 'other', + ]); + }); +}); diff --git a/src/components/hooks/useFilterParameters.test.ts b/src/components/hooks/useFilterParameters.test.ts new file mode 100644 index 000000000..8bed5ecae --- /dev/null +++ b/src/components/hooks/useFilterParameters.test.ts @@ -0,0 +1,123 @@ +import { renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +const nav: { pathname: string; query: Record } = { + pathname: '/websites/1', + query: {}, +}; + +let shareValue: any = null; + +vi.mock('./useNavigation', () => ({ + useNavigation: () => nav, +})); + +vi.mock('./context/useShare', () => ({ + useShare: () => shareValue, +})); + +import { useFilterParameters } from './useFilterParameters'; + +function setup(props?: { includePagination?: boolean }) { + return renderHook(() => useFilterParameters(props)).result.current; +} + +describe('useFilterParameters', () => { + beforeEach(() => { + nav.pathname = '/websites/1'; + nav.query = {}; + shareValue = null; + }); + + test('includes filter-column params and session property filters', () => { + nav.query = { path: 'x', spf1: 'y', notAField: 'z' }; + const params = setup(); + + expect(params.path).toBe('x'); + expect(params.spf1).toBe('y'); + expect(params).not.toHaveProperty('notAField'); + }); + + test('includes event property filters only on the events path', () => { + nav.pathname = '/websites/1/events'; + nav.query = { epf1: 'e' }; + const params = setup(); + + expect(params.epf1).toBe('e'); + }); + + test('excludes event property filters off the events path', () => { + nav.pathname = '/websites/1'; + nav.query = { epf1: 'e' }; + const params = setup(); + + expect(params).not.toHaveProperty('epf1'); + }); + + test('strips trailing index when matching filter columns', () => { + nav.query = { path12: 'x' }; + const params = setup(); + + expect(params.path12).toBe('x'); + }); + + test('passes through search, segment, cohort, excludeBounce and match when allowed', () => { + nav.query = { + search: 's', + segment: 'seg', + cohort: 'co', + excludeBounce: '1', + match: 'all', + }; + const params = setup(); + + expect(params).toMatchObject({ + search: 's', + segment: 'seg', + cohort: 'co', + excludeBounce: '1', + match: 'all', + }); + }); + + test('includes pagination by default', () => { + nav.query = { page: '2', pageSize: '25' }; + const params = setup(); + + expect(params.page).toBe('2'); + expect(params.pageSize).toBe('25'); + }); + + test('omits pagination when includePagination is false', () => { + nav.query = { page: '2', pageSize: '25' }; + const params = setup({ includePagination: false }); + + expect(params).not.toHaveProperty('page'); + expect(params).not.toHaveProperty('pageSize'); + }); + + test('drops filter params and share-gated params when filtering is disallowed', () => { + shareValue = { parameters: { allowFilter: false } }; + nav.query = { + path: 'x', + spf1: 'y', + search: 's', + segment: 'seg', + cohort: 'co', + excludeBounce: '1', + match: 'all', + }; + const params = setup(); + + // filter columns and property filters are excluded entirely + expect(params).not.toHaveProperty('path'); + expect(params).not.toHaveProperty('spf1'); + // share-gated params become undefined + expect(params.segment).toBeUndefined(); + expect(params.cohort).toBeUndefined(); + expect(params.excludeBounce).toBeUndefined(); + expect(params.match).toBeUndefined(); + // search is always passed through + expect(params.search).toBe('s'); + }); +}); diff --git a/src/components/hooks/useFilters.test.ts b/src/components/hooks/useFilters.test.ts new file mode 100644 index 000000000..cbc81e55b --- /dev/null +++ b/src/components/hooks/useFilters.test.ts @@ -0,0 +1,171 @@ +import { renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +const labels = new Proxy({} as Record, { + get: (_target, prop: string) => prop, +}); + +const nav: { pathname: string; query: Record } = { + pathname: '/websites/1', + query: {}, +}; + +let shareValue: any = null; + +const fields = [ + { name: 'path', label: 'Path' }, + { name: 'country', label: 'Country' }, +]; + +vi.mock('./useMessages', () => ({ + useMessages: () => ({ t: (value: string) => value, labels }), +})); + +vi.mock('./useNavigation', () => ({ + useNavigation: () => nav, +})); + +vi.mock('./useFields', () => ({ + useFields: () => ({ fields }), +})); + +vi.mock('./useOperatorLabels', () => ({ + useOperatorLabels: () => new Proxy({} as Record, { get: (_t, p: string) => p }), +})); + +vi.mock('./context/useShare', () => ({ + useShare: () => shareValue, +})); + +vi.mock('@/lib/params', () => ({ + parseSessionPropertyFilters: vi.fn(() => ['session-filter']), + parseUniversalEventPropertyFilters: vi.fn(() => ['event-filter']), +})); + +import { parseSessionPropertyFilters, parseUniversalEventPropertyFilters } from '@/lib/params'; +import { useFilters } from './useFilters'; + +function setup() { + return renderHook(() => useFilters()).result.current; +} + +describe('useFilters', () => { + beforeEach(() => { + nav.pathname = '/websites/1'; + nav.query = {}; + shareValue = null; + vi.clearAllMocks(); + }); + + describe('filters', () => { + test('builds a filter for a filterable column with a default operator', () => { + nav.query = { country: 'US' }; + const { filters } = setup(); + + expect(filters).toEqual([ + { name: 'country', type: 'country', operator: 'eq', value: 'US', label: 'Country' }, + ]); + }); + + test('parses an embedded operator prefix', () => { + nav.query = { path: 'c.blog' }; + const { filters } = setup(); + + expect(filters).toEqual([ + { name: 'path', type: 'path', operator: 'c', value: 'blog', label: 'Path' }, + ]); + }); + + test('strips a trailing index to resolve the base column name', () => { + nav.query = { path1: 'eq.home' }; + const { filters } = setup(); + + expect(filters).toEqual([ + { name: 'path1', type: 'path', operator: 'eq', value: 'home', label: 'Path' }, + ]); + }); + + test('ignores query keys that are not filter columns', () => { + nav.query = { page: '2', search: 'foo', path: 'x' }; + const { filters } = setup(); + + expect(filters.map(f => f.name)).toEqual(['path']); + }); + + test('decodes URI-encoded values', () => { + nav.query = { path: 'c.a%20b' }; + const { filters } = setup(); + + expect(filters[0].value).toBe('a b'); + }); + + test('returns no filters when sharing disallows filtering', () => { + shareValue = { parameters: { allowFilter: false } }; + nav.query = { path: 'c.blog' }; + const { filters } = setup(); + + expect(filters).toEqual([]); + }); + }); + + describe('property filters', () => { + test('includes event property filters only on the events path', () => { + nav.pathname = '/websites/1/events'; + const { eventPropertyFilters } = setup(); + + expect(eventPropertyFilters).toEqual(['event-filter']); + expect(parseUniversalEventPropertyFilters).toHaveBeenCalled(); + }); + + test('excludes event property filters off the events path', () => { + nav.pathname = '/websites/1'; + const { eventPropertyFilters } = setup(); + + expect(eventPropertyFilters).toEqual([]); + expect(parseUniversalEventPropertyFilters).not.toHaveBeenCalled(); + }); + + test('always includes session property filters when allowed', () => { + const { sessionPropertyFilters } = setup(); + + expect(sessionPropertyFilters).toEqual(['session-filter']); + expect(parseSessionPropertyFilters).toHaveBeenCalled(); + }); + + test('excludes property filters when sharing disallows filtering', () => { + shareValue = { parameters: { allowFilter: false } }; + nav.pathname = '/websites/1/events'; + const { eventPropertyFilters, sessionPropertyFilters } = setup(); + + expect(eventPropertyFilters).toEqual([]); + expect(sessionPropertyFilters).toEqual([]); + }); + }); + + describe('getFilters', () => { + test('maps operator keys to labelled options for a known type', () => { + const { getFilters } = setup(); + + expect(getFilters('string')).toEqual([ + { type: 'string', value: 'eq', label: 'eq' }, + { type: 'string', value: 'neq', label: 'neq' }, + { type: 'string', value: 'c', label: 'c' }, + { type: 'string', value: 'dnc', label: 'dnc' }, + { type: 'string', value: 're', label: 're' }, + { type: 'string', value: 'nre', label: 'nre' }, + ]); + }); + + test('returns an empty array for an unknown type', () => { + const { getFilters } = setup(); + expect(getFilters('nope')).toEqual([]); + }); + }); + + test('exposes the static operators list', () => { + const { operators } = setup(); + + expect(operators).toContainEqual({ name: 'gt', type: 'number', label: 'greaterThan' }); + expect(operators.filter(o => o.type === 'boolean')).toHaveLength(2); + }); +}); diff --git a/src/components/hooks/useFormat.test.ts b/src/components/hooks/useFormat.test.ts new file mode 100644 index 000000000..4eb316559 --- /dev/null +++ b/src/components/hooks/useFormat.test.ts @@ -0,0 +1,170 @@ +import { renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +const countryNames: Record = { + US: 'United States', + GB: 'United Kingdom', +}; +const languageNames: Record = { + en: 'English', +}; +const labels: Record = { + unknown: 'Unknown', + mobile: 'Mobile', + desktop: 'Desktop', +}; + +vi.mock('./useMessages', () => ({ + useMessages: () => ({ + t: (value: string) => value, + labels, + }), +})); + +vi.mock('./useLocale', () => ({ + useLocale: () => ({ locale: 'en-US' }), +})); + +vi.mock('./useCountryNames', () => ({ + useCountryNames: () => ({ countryNames }), +})); + +vi.mock('./useLanguageNames', () => ({ + useLanguageNames: () => ({ languageNames }), +})); + +import { useFormat } from './useFormat'; + +function setup() { + return renderHook(() => useFormat()).result.current; +} + +describe('useFormat', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('formatOS', () => { + test('maps known OS names', () => { + const { formatOS } = setup(); + expect(formatOS('Mac OS')).toBe('macOS'); + expect(formatOS('Windows 10')).toBe('Windows 10/11'); + }); + + test('returns the original value when unknown', () => { + const { formatOS } = setup(); + expect(formatOS('Haiku')).toBe('Haiku'); + }); + }); + + describe('formatBrowser', () => { + test('maps known browser keys', () => { + const { formatBrowser } = setup(); + expect(formatBrowser('chrome')).toBe('Chrome'); + expect(formatBrowser('edge-chromium')).toBe('Edge (Chromium)'); + }); + + test('returns the original value when unknown', () => { + const { formatBrowser } = setup(); + expect(formatBrowser('mystery')).toBe('mystery'); + }); + }); + + describe('formatDevice', () => { + test('translates a known device label', () => { + const { formatDevice } = setup(); + expect(formatDevice('mobile')).toBe('Mobile'); + }); + + test('falls back to the unknown label for missing devices', () => { + const { formatDevice } = setup(); + expect(formatDevice('foo')).toBe('Unknown'); + }); + }); + + describe('formatCountry', () => { + test('maps a known country code', () => { + const { formatCountry } = setup(); + expect(formatCountry('US')).toBe('United States'); + }); + + test('returns the original value when unknown', () => { + const { formatCountry } = setup(); + expect(formatCountry('ZZ')).toBe('ZZ'); + }); + }); + + describe('formatRegion', () => { + test('formats a known region as "Region, Country"', () => { + const { formatRegion } = setup(); + expect(formatRegion('US-CA')).toBe('California, United States'); + }); + + test('returns the original value for an unknown region', () => { + const { formatRegion } = setup(); + expect(formatRegion('ZZ-99')).toBe('ZZ-99'); + }); + + test('handles undefined without throwing', () => { + const { formatRegion } = setup(); + expect(formatRegion(undefined)).toBeUndefined(); + }); + }); + + describe('formatCity', () => { + test('appends the country name when known', () => { + const { formatCity } = setup(); + expect(formatCity('Portland', 'US')).toBe('Portland, United States'); + }); + + test('returns just the city when the country is unknown', () => { + const { formatCity } = setup(); + expect(formatCity('Portland', 'ZZ')).toBe('Portland'); + }); + + test('returns just the city when no country is provided', () => { + const { formatCity } = setup(); + expect(formatCity('Portland')).toBe('Portland'); + }); + }); + + describe('formatLanguage', () => { + test('maps the base language subtag', () => { + const { formatLanguage } = setup(); + expect(formatLanguage('en-US')).toBe('English'); + expect(formatLanguage('en')).toBe('English'); + }); + + test('returns the original value when unknown', () => { + const { formatLanguage } = setup(); + expect(formatLanguage('xx-YY')).toBe('xx-YY'); + }); + }); + + describe('formatValue', () => { + test('dispatches to the correct formatter by type', () => { + const { formatValue } = setup(); + expect(formatValue('Mac OS', 'os')).toBe('macOS'); + expect(formatValue('chrome', 'browser')).toBe('Chrome'); + expect(formatValue('mobile', 'device')).toBe('Mobile'); + expect(formatValue('US', 'country')).toBe('United States'); + expect(formatValue('US-CA', 'region')).toBe('California, United States'); + expect(formatValue('en-US', 'language')).toBe('English'); + }); + + test('passes the country from data through for city', () => { + const { formatValue } = setup(); + expect(formatValue('Portland', 'city', { country: 'US' })).toBe('Portland, United States'); + }); + + test('returns the raw string for unknown types', () => { + const { formatValue } = setup(); + expect(formatValue('anything', 'unknownType')).toBe('anything'); + }); + + test('returns undefined for a non-string value with an unknown type', () => { + const { formatValue } = setup(); + expect(formatValue(123 as unknown as string, 'unknownType')).toBeUndefined(); + }); + }); +}); diff --git a/src/permissions/entity.test.ts b/src/permissions/entity.test.ts new file mode 100644 index 000000000..52af85229 --- /dev/null +++ b/src/permissions/entity.test.ts @@ -0,0 +1,139 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { getEntity } from '@/lib/entity'; +import { getTeamUser } from '@/queries/prisma'; +import { canDeleteEntity, canUpdateEntity, canViewEntity } from './entity'; + +vi.mock('@/lib/entity', () => ({ + getEntity: vi.fn(), +})); + +vi.mock('@/queries/prisma', () => ({ + getTeamUser: vi.fn(), +})); + +vi.mock('@/lib/auth', async () => { + const { ROLE_PERMISSIONS } = await import('@/lib/constants'); + + return { + hasPermission: (role: string, permission: string | string[]) => + (Array.isArray(permission) ? permission : [permission]).some(p => + ROLE_PERMISSIONS[role]?.includes(p), + ), + }; +}); + +const adminUser = { id: 'admin-1', username: 'admin', role: 'admin', isAdmin: true }; +const normalUser = { id: 'user-1', username: 'user', role: 'user', isAdmin: false }; + +beforeEach(() => { + vi.mocked(getEntity).mockReset(); + vi.mocked(getTeamUser).mockReset(); +}); + +describe('canViewEntity', () => { + test('denies when there is no user', async () => { + await expect(canViewEntity({}, 'entity-1')).resolves.toBe(false); + expect(getEntity).not.toHaveBeenCalled(); + }); + + test('allows admins without a lookup', async () => { + await expect(canViewEntity({ user: adminUser }, 'entity-1')).resolves.toBe(true); + expect(getEntity).not.toHaveBeenCalled(); + }); + + test('denies when the entity is missing', async () => { + vi.mocked(getEntity).mockResolvedValue(null as any); + await expect(canViewEntity({ user: normalUser }, 'entity-1')).resolves.toBe(false); + }); + + test('allows the owner of a user-owned entity', async () => { + vi.mocked(getEntity).mockResolvedValue({ userId: 'user-1' } as any); + await expect(canViewEntity({ user: normalUser }, 'entity-1')).resolves.toBe(true); + }); + + test('denies a non-owner of a user-owned entity', async () => { + vi.mocked(getEntity).mockResolvedValue({ userId: 'other' } as any); + await expect(canViewEntity({ user: normalUser }, 'entity-1')).resolves.toBe(false); + }); + + test('allows any member of the owning team', async () => { + vi.mocked(getEntity).mockResolvedValue({ teamId: 'team-1' } as any); + vi.mocked(getTeamUser).mockResolvedValue({ role: 'team-view-only' } as any); + await expect(canViewEntity({ user: normalUser }, 'entity-1')).resolves.toBe(true); + }); + + test('denies a non-member of the owning team', async () => { + vi.mocked(getEntity).mockResolvedValue({ teamId: 'team-1' } as any); + vi.mocked(getTeamUser).mockResolvedValue(null as any); + await expect(canViewEntity({ user: normalUser }, 'entity-1')).resolves.toBe(false); + }); + + test('denies when the entity has neither userId nor teamId', async () => { + vi.mocked(getEntity).mockResolvedValue({} as any); + await expect(canViewEntity({ user: normalUser }, 'entity-1')).resolves.toBe(false); + }); +}); + +describe('canUpdateEntity', () => { + test('denies when there is no user', async () => { + await expect(canUpdateEntity({}, 'entity-1')).resolves.toBe(false); + }); + + test('allows admins', async () => { + await expect(canUpdateEntity({ user: adminUser }, 'entity-1')).resolves.toBe(true); + }); + + test('denies when the entity is missing', async () => { + vi.mocked(getEntity).mockResolvedValue(null as any); + await expect(canUpdateEntity({ user: normalUser }, 'entity-1')).resolves.toBe(false); + }); + + test('allows the owner of a user-owned entity', async () => { + vi.mocked(getEntity).mockResolvedValue({ userId: 'user-1' } as any); + await expect(canUpdateEntity({ user: normalUser }, 'entity-1')).resolves.toBe(true); + }); + + test('allows a team member with website:update', async () => { + vi.mocked(getEntity).mockResolvedValue({ teamId: 'team-1' } as any); + vi.mocked(getTeamUser).mockResolvedValue({ role: 'team-member' } as any); + await expect(canUpdateEntity({ user: normalUser }, 'entity-1')).resolves.toBe(true); + }); + + test('denies a team-view-only member', async () => { + vi.mocked(getEntity).mockResolvedValue({ teamId: 'team-1' } as any); + vi.mocked(getTeamUser).mockResolvedValue({ role: 'team-view-only' } as any); + await expect(canUpdateEntity({ user: normalUser }, 'entity-1')).resolves.toBe(false); + }); +}); + +describe('canDeleteEntity', () => { + test('denies when there is no user', async () => { + await expect(canDeleteEntity({}, 'entity-1')).resolves.toBe(false); + }); + + test('allows admins', async () => { + await expect(canDeleteEntity({ user: adminUser }, 'entity-1')).resolves.toBe(true); + }); + + test('denies when the entity is missing', async () => { + vi.mocked(getEntity).mockResolvedValue(null as any); + await expect(canDeleteEntity({ user: normalUser }, 'entity-1')).resolves.toBe(false); + }); + + test('allows the owner of a user-owned entity', async () => { + vi.mocked(getEntity).mockResolvedValue({ userId: 'user-1' } as any); + await expect(canDeleteEntity({ user: normalUser }, 'entity-1')).resolves.toBe(true); + }); + + test('allows a team member with website:delete', async () => { + vi.mocked(getEntity).mockResolvedValue({ teamId: 'team-1' } as any); + vi.mocked(getTeamUser).mockResolvedValue({ role: 'team-member' } as any); + await expect(canDeleteEntity({ user: normalUser }, 'entity-1')).resolves.toBe(true); + }); + + test('denies a team-view-only member', async () => { + vi.mocked(getEntity).mockResolvedValue({ teamId: 'team-1' } as any); + vi.mocked(getTeamUser).mockResolvedValue({ role: 'team-view-only' } as any); + await expect(canDeleteEntity({ user: normalUser }, 'entity-1')).resolves.toBe(false); + }); +}); diff --git a/src/permissions/link.test.ts b/src/permissions/link.test.ts new file mode 100644 index 000000000..d90915a93 --- /dev/null +++ b/src/permissions/link.test.ts @@ -0,0 +1,153 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { getLink, getTeamUser } from '@/queries/prisma'; +import { canDeleteLink, canUpdateLink, canViewLink } from './link'; + +vi.mock('@/queries/prisma', () => ({ + getLink: vi.fn(), + getTeamUser: vi.fn(), +})); + +vi.mock('@/lib/auth', async () => { + const { ROLE_PERMISSIONS } = await import('@/lib/constants'); + + return { + hasPermission: (role: string, permission: string | string[]) => + (Array.isArray(permission) ? permission : [permission]).some(p => + ROLE_PERMISSIONS[role]?.includes(p), + ), + }; +}); + +const adminUser = { id: 'admin-1', username: 'admin', role: 'admin', isAdmin: true }; +const normalUser = { id: 'user-1', username: 'user', role: 'user', isAdmin: false }; + +beforeEach(() => { + vi.mocked(getLink).mockReset(); + vi.mocked(getTeamUser).mockReset(); +}); + +describe('canViewLink', () => { + test('allows admins without a lookup', async () => { + await expect(canViewLink({ user: adminUser }, 'link-1')).resolves.toBe(true); + expect(getLink).not.toHaveBeenCalled(); + }); + + test('allows a matching linkId share token', async () => { + await expect(canViewLink({ shareToken: { linkId: 'link-1' } as any }, 'link-1')).resolves.toBe( + true, + ); + }); + + test('allows a matching websiteId share token', async () => { + await expect( + canViewLink({ shareToken: { websiteId: 'link-1' } as any }, 'link-1'), + ).resolves.toBe(true); + }); + + test('allows a link id in the share token list', async () => { + await expect( + canViewLink({ shareToken: { linkIds: ['link-1'] } as any }, 'link-1'), + ).resolves.toBe(true); + }); + + test('denies when there is no user and no matching token', async () => { + await expect(canViewLink({}, 'link-1')).resolves.toBe(false); + expect(getLink).not.toHaveBeenCalled(); + }); + + test('denies when the link is missing', async () => { + vi.mocked(getLink).mockResolvedValue(null as any); + await expect(canViewLink({ user: normalUser }, 'link-1')).resolves.toBe(false); + }); + + test('allows the owner of a user-owned link', async () => { + vi.mocked(getLink).mockResolvedValue({ userId: 'user-1' } as any); + await expect(canViewLink({ user: normalUser }, 'link-1')).resolves.toBe(true); + }); + + test('denies a non-owner of a user-owned link', async () => { + vi.mocked(getLink).mockResolvedValue({ userId: 'other' } as any); + await expect(canViewLink({ user: normalUser }, 'link-1')).resolves.toBe(false); + }); + + test('allows any member of the owning team', async () => { + vi.mocked(getLink).mockResolvedValue({ teamId: 'team-1' } as any); + vi.mocked(getTeamUser).mockResolvedValue({ role: 'team-view-only' } as any); + await expect(canViewLink({ user: normalUser }, 'link-1')).resolves.toBe(true); + }); + + test('denies a non-member of the owning team', async () => { + vi.mocked(getLink).mockResolvedValue({ teamId: 'team-1' } as any); + vi.mocked(getTeamUser).mockResolvedValue(null as any); + await expect(canViewLink({ user: normalUser }, 'link-1')).resolves.toBe(false); + }); + + test('denies when the link has neither userId nor teamId', async () => { + vi.mocked(getLink).mockResolvedValue({} as any); + await expect(canViewLink({ user: normalUser }, 'link-1')).resolves.toBe(false); + }); +}); + +describe('canUpdateLink', () => { + test('denies when there is no user', async () => { + await expect(canUpdateLink({}, 'link-1')).resolves.toBe(false); + }); + + test('allows admins', async () => { + await expect(canUpdateLink({ user: adminUser }, 'link-1')).resolves.toBe(true); + }); + + test('denies when the link is missing', async () => { + vi.mocked(getLink).mockResolvedValue(null as any); + await expect(canUpdateLink({ user: normalUser }, 'link-1')).resolves.toBe(false); + }); + + test('allows the owner of a user-owned link', async () => { + vi.mocked(getLink).mockResolvedValue({ userId: 'user-1' } as any); + await expect(canUpdateLink({ user: normalUser }, 'link-1')).resolves.toBe(true); + }); + + test('allows a team member with website:update', async () => { + vi.mocked(getLink).mockResolvedValue({ teamId: 'team-1' } as any); + vi.mocked(getTeamUser).mockResolvedValue({ role: 'team-member' } as any); + await expect(canUpdateLink({ user: normalUser }, 'link-1')).resolves.toBe(true); + }); + + test('denies a team-view-only member', async () => { + vi.mocked(getLink).mockResolvedValue({ teamId: 'team-1' } as any); + vi.mocked(getTeamUser).mockResolvedValue({ role: 'team-view-only' } as any); + await expect(canUpdateLink({ user: normalUser }, 'link-1')).resolves.toBe(false); + }); +}); + +describe('canDeleteLink', () => { + test('denies when there is no user', async () => { + await expect(canDeleteLink({}, 'link-1')).resolves.toBe(false); + }); + + test('allows admins', async () => { + await expect(canDeleteLink({ user: adminUser }, 'link-1')).resolves.toBe(true); + }); + + test('denies when the link is missing', async () => { + vi.mocked(getLink).mockResolvedValue(null as any); + await expect(canDeleteLink({ user: normalUser }, 'link-1')).resolves.toBe(false); + }); + + test('allows the owner of a user-owned link', async () => { + vi.mocked(getLink).mockResolvedValue({ userId: 'user-1' } as any); + await expect(canDeleteLink({ user: normalUser }, 'link-1')).resolves.toBe(true); + }); + + test('allows a team member with website:delete', async () => { + vi.mocked(getLink).mockResolvedValue({ teamId: 'team-1' } as any); + vi.mocked(getTeamUser).mockResolvedValue({ role: 'team-member' } as any); + await expect(canDeleteLink({ user: normalUser }, 'link-1')).resolves.toBe(true); + }); + + test('denies a team-view-only member', async () => { + vi.mocked(getLink).mockResolvedValue({ teamId: 'team-1' } as any); + vi.mocked(getTeamUser).mockResolvedValue({ role: 'team-view-only' } as any); + await expect(canDeleteLink({ user: normalUser }, 'link-1')).resolves.toBe(false); + }); +}); diff --git a/src/permissions/pixel.test.ts b/src/permissions/pixel.test.ts new file mode 100644 index 000000000..4865a7b96 --- /dev/null +++ b/src/permissions/pixel.test.ts @@ -0,0 +1,153 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { getPixel, getTeamUser } from '@/queries/prisma'; +import { canDeletePixel, canUpdatePixel, canViewPixel } from './pixel'; + +vi.mock('@/queries/prisma', () => ({ + getPixel: vi.fn(), + getTeamUser: vi.fn(), +})); + +vi.mock('@/lib/auth', async () => { + const { ROLE_PERMISSIONS } = await import('@/lib/constants'); + + return { + hasPermission: (role: string, permission: string | string[]) => + (Array.isArray(permission) ? permission : [permission]).some(p => + ROLE_PERMISSIONS[role]?.includes(p), + ), + }; +}); + +const adminUser = { id: 'admin-1', username: 'admin', role: 'admin', isAdmin: true }; +const normalUser = { id: 'user-1', username: 'user', role: 'user', isAdmin: false }; + +beforeEach(() => { + vi.mocked(getPixel).mockReset(); + vi.mocked(getTeamUser).mockReset(); +}); + +describe('canViewPixel', () => { + test('allows admins without a lookup', async () => { + await expect(canViewPixel({ user: adminUser }, 'pixel-1')).resolves.toBe(true); + expect(getPixel).not.toHaveBeenCalled(); + }); + + test('allows a matching pixelId share token', async () => { + await expect( + canViewPixel({ shareToken: { pixelId: 'pixel-1' } as any }, 'pixel-1'), + ).resolves.toBe(true); + }); + + test('allows a matching websiteId share token', async () => { + await expect( + canViewPixel({ shareToken: { websiteId: 'pixel-1' } as any }, 'pixel-1'), + ).resolves.toBe(true); + }); + + test('allows a pixel id in the share token list', async () => { + await expect( + canViewPixel({ shareToken: { pixelIds: ['pixel-1'] } as any }, 'pixel-1'), + ).resolves.toBe(true); + }); + + test('denies when there is no user and no matching token', async () => { + await expect(canViewPixel({}, 'pixel-1')).resolves.toBe(false); + expect(getPixel).not.toHaveBeenCalled(); + }); + + test('denies when the pixel is missing', async () => { + vi.mocked(getPixel).mockResolvedValue(null as any); + await expect(canViewPixel({ user: normalUser }, 'pixel-1')).resolves.toBe(false); + }); + + test('allows the owner of a user-owned pixel', async () => { + vi.mocked(getPixel).mockResolvedValue({ userId: 'user-1' } as any); + await expect(canViewPixel({ user: normalUser }, 'pixel-1')).resolves.toBe(true); + }); + + test('denies a non-owner of a user-owned pixel', async () => { + vi.mocked(getPixel).mockResolvedValue({ userId: 'other' } as any); + await expect(canViewPixel({ user: normalUser }, 'pixel-1')).resolves.toBe(false); + }); + + test('allows any member of the owning team', async () => { + vi.mocked(getPixel).mockResolvedValue({ teamId: 'team-1' } as any); + vi.mocked(getTeamUser).mockResolvedValue({ role: 'team-view-only' } as any); + await expect(canViewPixel({ user: normalUser }, 'pixel-1')).resolves.toBe(true); + }); + + test('denies a non-member of the owning team', async () => { + vi.mocked(getPixel).mockResolvedValue({ teamId: 'team-1' } as any); + vi.mocked(getTeamUser).mockResolvedValue(null as any); + await expect(canViewPixel({ user: normalUser }, 'pixel-1')).resolves.toBe(false); + }); + + test('denies when the pixel has neither userId nor teamId', async () => { + vi.mocked(getPixel).mockResolvedValue({} as any); + await expect(canViewPixel({ user: normalUser }, 'pixel-1')).resolves.toBe(false); + }); +}); + +describe('canUpdatePixel', () => { + test('denies when there is no user', async () => { + await expect(canUpdatePixel({}, 'pixel-1')).resolves.toBe(false); + }); + + test('allows admins', async () => { + await expect(canUpdatePixel({ user: adminUser }, 'pixel-1')).resolves.toBe(true); + }); + + test('denies when the pixel is missing', async () => { + vi.mocked(getPixel).mockResolvedValue(null as any); + await expect(canUpdatePixel({ user: normalUser }, 'pixel-1')).resolves.toBe(false); + }); + + test('allows the owner of a user-owned pixel', async () => { + vi.mocked(getPixel).mockResolvedValue({ userId: 'user-1' } as any); + await expect(canUpdatePixel({ user: normalUser }, 'pixel-1')).resolves.toBe(true); + }); + + test('allows a team member with website:update', async () => { + vi.mocked(getPixel).mockResolvedValue({ teamId: 'team-1' } as any); + vi.mocked(getTeamUser).mockResolvedValue({ role: 'team-member' } as any); + await expect(canUpdatePixel({ user: normalUser }, 'pixel-1')).resolves.toBe(true); + }); + + test('denies a team-view-only member', async () => { + vi.mocked(getPixel).mockResolvedValue({ teamId: 'team-1' } as any); + vi.mocked(getTeamUser).mockResolvedValue({ role: 'team-view-only' } as any); + await expect(canUpdatePixel({ user: normalUser }, 'pixel-1')).resolves.toBe(false); + }); +}); + +describe('canDeletePixel', () => { + test('denies when there is no user', async () => { + await expect(canDeletePixel({}, 'pixel-1')).resolves.toBe(false); + }); + + test('allows admins', async () => { + await expect(canDeletePixel({ user: adminUser }, 'pixel-1')).resolves.toBe(true); + }); + + test('denies when the pixel is missing', async () => { + vi.mocked(getPixel).mockResolvedValue(null as any); + await expect(canDeletePixel({ user: normalUser }, 'pixel-1')).resolves.toBe(false); + }); + + test('allows the owner of a user-owned pixel', async () => { + vi.mocked(getPixel).mockResolvedValue({ userId: 'user-1' } as any); + await expect(canDeletePixel({ user: normalUser }, 'pixel-1')).resolves.toBe(true); + }); + + test('allows a team member with website:delete', async () => { + vi.mocked(getPixel).mockResolvedValue({ teamId: 'team-1' } as any); + vi.mocked(getTeamUser).mockResolvedValue({ role: 'team-member' } as any); + await expect(canDeletePixel({ user: normalUser }, 'pixel-1')).resolves.toBe(true); + }); + + test('denies a team-view-only member', async () => { + vi.mocked(getPixel).mockResolvedValue({ teamId: 'team-1' } as any); + vi.mocked(getTeamUser).mockResolvedValue({ role: 'team-view-only' } as any); + await expect(canDeletePixel({ user: normalUser }, 'pixel-1')).resolves.toBe(false); + }); +}); diff --git a/src/permissions/team.test.ts b/src/permissions/team.test.ts new file mode 100644 index 000000000..42c086197 --- /dev/null +++ b/src/permissions/team.test.ts @@ -0,0 +1,204 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { getTeamUser } from '@/queries/prisma'; +import { + canCreateTeam, + canCreateTeamWebsite, + canDeleteTeam, + canDeleteTeamUser, + canEnforceTwoFactorAuthForTeam, + canUpdateTeam, + canViewAllTeams, + canViewTeam, +} from './team'; + +vi.mock('@/queries/prisma', () => ({ + getTeamUser: vi.fn(), +})); + +vi.mock('@/lib/auth', async () => { + const { ROLE_PERMISSIONS } = await import('@/lib/constants'); + + return { + hasPermission: (role: string, permission: string | string[]) => + (Array.isArray(permission) ? permission : [permission]).some(p => + ROLE_PERMISSIONS[role]?.includes(p), + ), + }; +}); + +const adminUser = { id: 'admin-1', username: 'admin', role: 'admin', isAdmin: true }; +const normalUser = { id: 'user-1', username: 'user', role: 'user', isAdmin: false }; +const viewOnlyUser = { id: 'user-2', username: 'viewer', role: 'view-only', isAdmin: false }; + +beforeEach(() => { + vi.mocked(getTeamUser).mockReset(); +}); + +describe('canViewTeam', () => { + test('denies when there is no user', async () => { + await expect(canViewTeam({}, 'team-1')).resolves.toBe(false); + }); + + test('allows admins without a membership lookup', async () => { + await expect(canViewTeam({ user: adminUser }, 'team-1')).resolves.toBe(true); + expect(getTeamUser).not.toHaveBeenCalled(); + }); + + test('returns the team membership for a member', async () => { + const membership = { role: 'team-member' }; + vi.mocked(getTeamUser).mockResolvedValue(membership as any); + await expect(canViewTeam({ user: normalUser }, 'team-1')).resolves.toBe(membership); + }); + + test('returns falsy for a non-member', async () => { + vi.mocked(getTeamUser).mockResolvedValue(null as any); + await expect(canViewTeam({ user: normalUser }, 'team-1')).resolves.toBeNull(); + }); +}); + +describe('canCreateTeam', () => { + test('denies when there is no user', async () => { + await expect(canCreateTeam({})).resolves.toBe(false); + }); + + test('allows admins', async () => { + await expect(canCreateTeam({ user: adminUser })).resolves.toBe(true); + }); + + test('allows a user role', async () => { + await expect(canCreateTeam({ user: normalUser })).resolves.toBe(true); + }); + + test('denies a view-only role', async () => { + await expect(canCreateTeam({ user: viewOnlyUser })).resolves.toBe(false); + }); +}); + +describe('canUpdateTeam', () => { + test('denies when there is no user', async () => { + await expect(canUpdateTeam({}, 'team-1')).resolves.toBe(false); + }); + + test('allows admins', async () => { + await expect(canUpdateTeam({ user: adminUser }, 'team-1')).resolves.toBe(true); + }); + + test('allows a team owner', async () => { + vi.mocked(getTeamUser).mockResolvedValue({ role: 'team-owner' } as any); + await expect(canUpdateTeam({ user: normalUser }, 'team-1')).resolves.toBe(true); + }); + + test('allows a team manager', async () => { + vi.mocked(getTeamUser).mockResolvedValue({ role: 'team-manager' } as any); + await expect(canUpdateTeam({ user: normalUser }, 'team-1')).resolves.toBe(true); + }); + + test('denies a team member (lacks team:update)', async () => { + vi.mocked(getTeamUser).mockResolvedValue({ role: 'team-member' } as any); + await expect(canUpdateTeam({ user: normalUser }, 'team-1')).resolves.toBe(false); + }); + + test('returns falsy for a non-member', async () => { + vi.mocked(getTeamUser).mockResolvedValue(null as any); + await expect(canUpdateTeam({ user: normalUser }, 'team-1')).resolves.toBeNull(); + }); +}); + +describe('canDeleteTeam', () => { + test('denies when there is no user', async () => { + await expect(canDeleteTeam({}, 'team-1')).resolves.toBe(false); + }); + + test('allows admins', async () => { + await expect(canDeleteTeam({ user: adminUser }, 'team-1')).resolves.toBe(true); + }); + + test('allows a team owner', async () => { + vi.mocked(getTeamUser).mockResolvedValue({ role: 'team-owner' } as any); + await expect(canDeleteTeam({ user: normalUser }, 'team-1')).resolves.toBe(true); + }); + + test('denies a team manager (lacks team:delete)', async () => { + vi.mocked(getTeamUser).mockResolvedValue({ role: 'team-manager' } as any); + await expect(canDeleteTeam({ user: normalUser }, 'team-1')).resolves.toBe(false); + }); +}); + +describe('canDeleteTeamUser', () => { + test('denies when there is no user', async () => { + await expect(canDeleteTeamUser({}, 'team-1', 'remove-1')).resolves.toBe(false); + }); + + test('allows admins', async () => { + await expect(canDeleteTeamUser({ user: adminUser }, 'team-1', 'remove-1')).resolves.toBe(true); + }); + + test('allows a user to remove themselves', async () => { + await expect(canDeleteTeamUser({ user: normalUser }, 'team-1', 'user-1')).resolves.toBe(true); + expect(getTeamUser).not.toHaveBeenCalled(); + }); + + test('allows a team manager to remove another user', async () => { + vi.mocked(getTeamUser).mockResolvedValue({ role: 'team-manager' } as any); + await expect(canDeleteTeamUser({ user: normalUser }, 'team-1', 'other-user')).resolves.toBe( + true, + ); + }); + + test('denies a team member removing another user', async () => { + vi.mocked(getTeamUser).mockResolvedValue({ role: 'team-member' } as any); + await expect(canDeleteTeamUser({ user: normalUser }, 'team-1', 'other-user')).resolves.toBe( + false, + ); + }); +}); + +describe('canCreateTeamWebsite', () => { + test('denies when there is no user', async () => { + await expect(canCreateTeamWebsite({}, 'team-1')).resolves.toBe(false); + }); + + test('allows admins', async () => { + await expect(canCreateTeamWebsite({ user: adminUser }, 'team-1')).resolves.toBe(true); + }); + + test('allows a team member with website:create', async () => { + vi.mocked(getTeamUser).mockResolvedValue({ role: 'team-member' } as any); + await expect(canCreateTeamWebsite({ user: normalUser }, 'team-1')).resolves.toBe(true); + }); + + test('denies a team-view-only member', async () => { + vi.mocked(getTeamUser).mockResolvedValue({ role: 'team-view-only' } as any); + await expect(canCreateTeamWebsite({ user: normalUser }, 'team-1')).resolves.toBe(false); + }); +}); + +describe('canViewAllTeams', () => { + test('allows admins', async () => { + await expect(canViewAllTeams({ user: adminUser })).resolves.toBe(true); + }); + + test('denies non-admins', async () => { + await expect(canViewAllTeams({ user: normalUser })).resolves.toBe(false); + }); + + test('denies when there is no user', async () => { + await expect(canViewAllTeams({})).resolves.toBe(false); + }); +}); + +describe('canEnforceTwoFactorAuthForTeam', () => { + test('denies when there is no user', async () => { + await expect(canEnforceTwoFactorAuthForTeam({}, 'team-1')).resolves.toBe(false); + }); + + test('allows admins', async () => { + await expect(canEnforceTwoFactorAuthForTeam({ user: adminUser }, 'team-1')).resolves.toBe(true); + }); + + test('denies non-admins', async () => { + await expect(canEnforceTwoFactorAuthForTeam({ user: normalUser }, 'team-1')).resolves.toBe( + false, + ); + }); +}); diff --git a/src/permissions/website.test.ts b/src/permissions/website.test.ts new file mode 100644 index 000000000..dbc53cab2 --- /dev/null +++ b/src/permissions/website.test.ts @@ -0,0 +1,356 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { getEntity } from '@/lib/entity'; +import prisma from '@/lib/prisma'; +import { getTeamUser, getWebsite } from '@/queries/prisma'; +import { + canCreateWebsite, + canDeleteWebsite, + canTransferWebsiteToTeam, + canTransferWebsiteToUser, + canUpdateWebsite, + canViewAllWebsites, + canViewBatchWebsites, + canViewWebsite, +} from './website'; + +vi.mock('@/lib/entity', () => ({ + getEntity: vi.fn(), +})); + +vi.mock('@/queries/prisma', () => ({ + getWebsite: vi.fn(), + getTeamUser: vi.fn(), +})); + +vi.mock('@/lib/prisma', () => ({ + default: { + client: { + website: { + findMany: vi.fn(), + }, + teamUser: { + findMany: vi.fn(), + }, + }, + }, +})); + +vi.mock('@/lib/auth', async () => { + const { ROLE_PERMISSIONS } = await import('@/lib/constants'); + + return { + hasPermission: (role: string, permission: string | string[]) => + (Array.isArray(permission) ? permission : [permission]).some(p => + ROLE_PERMISSIONS[role]?.includes(p), + ), + }; +}); + +const adminUser = { id: 'admin-1', username: 'admin', role: 'admin', isAdmin: true }; +const normalUser = { id: 'user-1', username: 'user', role: 'user', isAdmin: false }; +const viewOnlyUser = { id: 'user-2', username: 'viewer', role: 'view-only', isAdmin: false }; + +beforeEach(() => { + vi.mocked(getEntity).mockReset(); + vi.mocked(getWebsite).mockReset(); + vi.mocked(getTeamUser).mockReset(); + vi.mocked(prisma.client.website.findMany).mockReset(); + vi.mocked(prisma.client.teamUser.findMany).mockReset(); +}); + +describe('canViewWebsite', () => { + test('allows admins without any lookup', async () => { + await expect(canViewWebsite({ user: adminUser }, 'website-1')).resolves.toBe(true); + expect(getEntity).not.toHaveBeenCalled(); + }); + + test('allows a matching single share token id', async () => { + await expect( + canViewWebsite({ shareToken: { websiteId: 'website-1' } as any }, 'website-1'), + ).resolves.toBe(true); + }); + + test('allows a website id present in share token list ids', async () => { + await expect( + canViewWebsite({ shareToken: { websiteIds: ['website-1'] } as any }, 'website-1'), + ).resolves.toBe(true); + }); + + test('allows a pixel share token to view the matching id', async () => { + await expect( + canViewWebsite({ shareToken: { pixelId: 'website-1' } as any }, 'website-1'), + ).resolves.toBe(true); + }); + + test('denies when entity is missing', async () => { + vi.mocked(getEntity).mockResolvedValue(null as any); + await expect(canViewWebsite({ user: normalUser }, 'website-1')).resolves.toBe(false); + }); + + test('denies when there is no user and no matching share token', async () => { + vi.mocked(getEntity).mockResolvedValue({ userId: 'user-1' } as any); + await expect(canViewWebsite({}, 'website-1')).resolves.toBe(false); + }); + + test('allows the owner of a user-owned website', async () => { + vi.mocked(getEntity).mockResolvedValue({ userId: 'user-1' } as any); + await expect(canViewWebsite({ user: normalUser }, 'website-1')).resolves.toBe(true); + }); + + test('denies a non-owner of a user-owned website', async () => { + vi.mocked(getEntity).mockResolvedValue({ userId: 'other-user' } as any); + await expect(canViewWebsite({ user: normalUser }, 'website-1')).resolves.toBe(false); + }); + + test('allows a member of the owning team', async () => { + vi.mocked(getEntity).mockResolvedValue({ teamId: 'team-1' } as any); + vi.mocked(getTeamUser).mockResolvedValue({ role: 'team-member' } as any); + await expect(canViewWebsite({ user: normalUser }, 'website-1')).resolves.toBe(true); + }); + + test('denies a non-member of the owning team', async () => { + vi.mocked(getEntity).mockResolvedValue({ teamId: 'team-1' } as any); + vi.mocked(getTeamUser).mockResolvedValue(null as any); + await expect(canViewWebsite({ user: normalUser }, 'website-1')).resolves.toBe(false); + }); + + test('denies when entity has neither userId nor teamId', async () => { + vi.mocked(getEntity).mockResolvedValue({} as any); + await expect(canViewWebsite({ user: normalUser }, 'website-1')).resolves.toBe(false); + }); +}); + +describe('canViewBatchWebsites', () => { + test('returns empty array for empty input', async () => { + await expect(canViewBatchWebsites({ user: normalUser }, [])).resolves.toEqual([]); + }); + + test('returns all deduped ids for admins', async () => { + await expect(canViewBatchWebsites({ user: adminUser }, ['a', 'a', 'b'])).resolves.toEqual([ + 'a', + 'b', + ]); + }); + + test('returns only share-allowed ids when there is no user', async () => { + await expect( + canViewBatchWebsites({ shareToken: { websiteIds: ['a'] } as any }, ['a', 'b']), + ).resolves.toEqual(['a']); + }); + + test('returns owned, team, and share allowed ids for a user', async () => { + vi.mocked(prisma.client.website.findMany).mockResolvedValue([ + { id: 'owned', userId: 'user-1', teamId: null }, + { id: 'team', userId: null, teamId: 'team-1' }, + { id: 'foreign', userId: 'other', teamId: null }, + ] as any); + vi.mocked(prisma.client.teamUser.findMany).mockResolvedValue([{ teamId: 'team-1' }] as any); + + await expect( + canViewBatchWebsites({ user: normalUser, shareToken: { websiteId: 'shared' } as any }, [ + 'owned', + 'team', + 'foreign', + 'shared', + ]), + ).resolves.toEqual(['owned', 'team', 'shared']); + }); + + test('excludes team websites when the user is not a team member', async () => { + vi.mocked(prisma.client.website.findMany).mockResolvedValue([ + { id: 'team', userId: null, teamId: 'team-1' }, + ] as any); + vi.mocked(prisma.client.teamUser.findMany).mockResolvedValue([] as any); + + await expect(canViewBatchWebsites({ user: normalUser }, ['team'])).resolves.toEqual([]); + }); +}); + +describe('canViewAllWebsites', () => { + test('allows admins', async () => { + await expect(canViewAllWebsites({ user: adminUser })).resolves.toBe(true); + }); + + test('denies non-admins', async () => { + await expect(canViewAllWebsites({ user: normalUser })).resolves.toBe(false); + }); + + test('denies when there is no user', async () => { + await expect(canViewAllWebsites({})).resolves.toBe(false); + }); +}); + +describe('canCreateWebsite', () => { + test('denies when there is no user', async () => { + await expect(canCreateWebsite({})).resolves.toBe(false); + }); + + test('allows admins', async () => { + await expect(canCreateWebsite({ user: adminUser })).resolves.toBe(true); + }); + + test('allows a user role', async () => { + await expect(canCreateWebsite({ user: normalUser })).resolves.toBe(true); + }); + + test('denies a view-only role', async () => { + await expect(canCreateWebsite({ user: viewOnlyUser })).resolves.toBe(false); + }); +}); + +describe('canUpdateWebsite', () => { + test('denies when there is no user', async () => { + await expect(canUpdateWebsite({}, 'website-1')).resolves.toBe(false); + }); + + test('allows admins', async () => { + await expect(canUpdateWebsite({ user: adminUser }, 'website-1')).resolves.toBe(true); + }); + + test('denies when website is missing', async () => { + vi.mocked(getWebsite).mockResolvedValue(null as any); + await expect(canUpdateWebsite({ user: normalUser }, 'website-1')).resolves.toBe(false); + }); + + test('allows the owner of a user-owned website', async () => { + vi.mocked(getWebsite).mockResolvedValue({ userId: 'user-1' } as any); + await expect(canUpdateWebsite({ user: normalUser }, 'website-1')).resolves.toBe(true); + }); + + test('denies a non-owner of a user-owned website', async () => { + vi.mocked(getWebsite).mockResolvedValue({ userId: 'other' } as any); + await expect(canUpdateWebsite({ user: normalUser }, 'website-1')).resolves.toBe(false); + }); + + test('allows a team member with website:update permission', async () => { + vi.mocked(getWebsite).mockResolvedValue({ teamId: 'team-1' } as any); + vi.mocked(getTeamUser).mockResolvedValue({ role: 'team-member' } as any); + await expect(canUpdateWebsite({ user: normalUser }, 'website-1')).resolves.toBe(true); + }); + + test('denies a team-view-only member', async () => { + vi.mocked(getWebsite).mockResolvedValue({ teamId: 'team-1' } as any); + vi.mocked(getTeamUser).mockResolvedValue({ role: 'team-view-only' } as any); + await expect(canUpdateWebsite({ user: normalUser }, 'website-1')).resolves.toBe(false); + }); +}); + +describe('canDeleteWebsite', () => { + test('denies when there is no user', async () => { + await expect(canDeleteWebsite({}, 'website-1')).resolves.toBe(false); + }); + + test('allows admins', async () => { + await expect(canDeleteWebsite({ user: adminUser }, 'website-1')).resolves.toBe(true); + }); + + test('denies when website is missing', async () => { + vi.mocked(getWebsite).mockResolvedValue(null as any); + await expect(canDeleteWebsite({ user: normalUser }, 'website-1')).resolves.toBe(false); + }); + + test('allows the owner of a user-owned website', async () => { + vi.mocked(getWebsite).mockResolvedValue({ userId: 'user-1' } as any); + await expect(canDeleteWebsite({ user: normalUser }, 'website-1')).resolves.toBe(true); + }); + + test('allows a team member with website:delete permission', async () => { + vi.mocked(getWebsite).mockResolvedValue({ teamId: 'team-1' } as any); + vi.mocked(getTeamUser).mockResolvedValue({ role: 'team-member' } as any); + await expect(canDeleteWebsite({ user: normalUser }, 'website-1')).resolves.toBe(true); + }); + + test('denies a team-view-only member', async () => { + vi.mocked(getWebsite).mockResolvedValue({ teamId: 'team-1' } as any); + vi.mocked(getTeamUser).mockResolvedValue({ role: 'team-view-only' } as any); + await expect(canDeleteWebsite({ user: normalUser }, 'website-1')).resolves.toBe(false); + }); +}); + +describe('canTransferWebsiteToUser', () => { + test('denies when there is no user', async () => { + await expect(canTransferWebsiteToUser({}, 'website-1', 'user-1')).resolves.toBe(false); + }); + + test('denies when website is missing', async () => { + vi.mocked(getWebsite).mockResolvedValue(null as any); + await expect( + canTransferWebsiteToUser({ user: normalUser }, 'website-1', 'user-1'), + ).resolves.toBe(false); + }); + + // NOTE: admins are NOT short-circuited here; there is no isAdmin bypass in this function. + test('denies an admin transferring a user-owned website (no team)', async () => { + vi.mocked(getWebsite).mockResolvedValue({ userId: 'admin-1' } as any); + await expect( + canTransferWebsiteToUser({ user: adminUser }, 'website-1', 'admin-1'), + ).resolves.toBe(false); + }); + + test('allows a team owner transferring a team website to themselves', async () => { + vi.mocked(getWebsite).mockResolvedValue({ teamId: 'team-1' } as any); + vi.mocked(getTeamUser).mockResolvedValue({ role: 'team-owner' } as any); + await expect( + canTransferWebsiteToUser({ user: normalUser }, 'website-1', 'user-1'), + ).resolves.toBe(true); + }); + + test('denies a team manager (lacks website:transfer-to-user)', async () => { + vi.mocked(getWebsite).mockResolvedValue({ teamId: 'team-1' } as any); + vi.mocked(getTeamUser).mockResolvedValue({ role: 'team-manager' } as any); + await expect( + canTransferWebsiteToUser({ user: normalUser }, 'website-1', 'user-1'), + ).resolves.toBe(false); + }); + + test('denies transferring to a different user id', async () => { + vi.mocked(getWebsite).mockResolvedValue({ teamId: 'team-1' } as any); + await expect( + canTransferWebsiteToUser({ user: normalUser }, 'website-1', 'other-user'), + ).resolves.toBe(false); + }); +}); + +describe('canTransferWebsiteToTeam', () => { + test('denies when there is no user', async () => { + await expect(canTransferWebsiteToTeam({}, 'website-1', 'team-1')).resolves.toBe(false); + }); + + test('denies when website is missing', async () => { + vi.mocked(getWebsite).mockResolvedValue(null as any); + await expect( + canTransferWebsiteToTeam({ user: normalUser }, 'website-1', 'team-1'), + ).resolves.toBe(false); + }); + + test('allows an owner who is a team owner of the destination team', async () => { + vi.mocked(getWebsite).mockResolvedValue({ userId: 'user-1' } as any); + vi.mocked(getTeamUser).mockResolvedValue({ role: 'team-owner' } as any); + await expect( + canTransferWebsiteToTeam({ user: normalUser }, 'website-1', 'team-1'), + ).resolves.toBe(true); + }); + + test('allows an owner who is a team manager of the destination team', async () => { + vi.mocked(getWebsite).mockResolvedValue({ userId: 'user-1' } as any); + vi.mocked(getTeamUser).mockResolvedValue({ role: 'team-manager' } as any); + await expect( + canTransferWebsiteToTeam({ user: normalUser }, 'website-1', 'team-1'), + ).resolves.toBe(true); + }); + + test('denies a team member of the destination team', async () => { + vi.mocked(getWebsite).mockResolvedValue({ userId: 'user-1' } as any); + vi.mocked(getTeamUser).mockResolvedValue({ role: 'team-member' } as any); + await expect( + canTransferWebsiteToTeam({ user: normalUser }, 'website-1', 'team-1'), + ).resolves.toBe(false); + }); + + test('denies when the website is not owned by the user', async () => { + vi.mocked(getWebsite).mockResolvedValue({ userId: 'other' } as any); + await expect( + canTransferWebsiteToTeam({ user: normalUser }, 'website-1', 'team-1'), + ).resolves.toBe(false); + }); +}); diff --git a/src/queries/sql/getChannelExpandedMetrics.test.ts b/src/queries/sql/getChannelExpandedMetrics.test.ts new file mode 100644 index 000000000..b9b403138 --- /dev/null +++ b/src/queries/sql/getChannelExpandedMetrics.test.ts @@ -0,0 +1,164 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { + EMAIL_DOMAINS, + LLM_DOMAINS, + PAID_AD_PARAMS, + SEARCH_DOMAINS, + SHOPPING_DOMAINS, + SOCIAL_DOMAINS, + VIDEO_DOMAINS, +} from '@/lib/constants'; +import { getChannelExpandedMetrics } from './getChannelExpandedMetrics'; + +const { + state, + prismaRawQuery, + prismaParseFilters, + getTimestampDiffSQL, + clickhouseRawQuery, + clickhouseParseFilters, +} = vi.hoisted(() => ({ + state: { mode: 'prisma' as 'prisma' | 'clickhouse' }, + prismaRawQuery: vi.fn(), + prismaParseFilters: vi.fn(), + getTimestampDiffSQL: vi.fn(), + clickhouseRawQuery: vi.fn(), + clickhouseParseFilters: vi.fn(), +})); + +vi.mock('@/lib/db', () => ({ + CLICKHOUSE: 'clickhouse', + PRISMA: 'prisma', + runQuery: vi.fn((queries: Record unknown>) => queries[state.mode]()), +})); + +vi.mock('@/lib/prisma', () => ({ + default: { + rawQuery: prismaRawQuery, + parseFilters: prismaParseFilters, + getTimestampDiffSQL, + }, +})); + +vi.mock('@/lib/clickhouse', () => ({ + default: { + rawQuery: clickhouseRawQuery, + parseFilters: clickhouseParseFilters, + }, +})); + +const parseFiltersResult = { + queryParams: { websiteId: 'website-1' }, + filterQuery: 'and website_event.utm_source = {{utmSource}}', + joinSessionQuery: 'join session on session.session_id = website_event.session_id', + cohortQuery: 'join cohort on cohort.session_id = website_event.session_id', + excludeBounceQuery: 'and website_event.visit_id in (select visit_id from bounces)', + dateQuery: 'and created_at between {{startDate}} and {{endDate}}', +}; + +beforeEach(() => { + state.mode = 'prisma'; + prismaRawQuery.mockReset(); + prismaParseFilters.mockReset(); + getTimestampDiffSQL.mockReset(); + clickhouseRawQuery.mockReset(); + clickhouseParseFilters.mockReset(); + + prismaParseFilters.mockReturnValue(parseFiltersResult); + clickhouseParseFilters.mockReturnValue(parseFiltersResult); + getTimestampDiffSQL.mockReturnValue('ts_diff(min_time, max_time)'); + prismaRawQuery.mockResolvedValue([]); + clickhouseRawQuery.mockResolvedValue([]); +}); + +describe('getChannelExpandedMetrics postgres branch', () => { + test('builds the expanded aggregation query with parseFilters fragments', async () => { + await getChannelExpandedMetrics('website-1', {}); + + expect(prismaRawQuery).toHaveBeenCalledTimes(1); + const [query, params, tag] = prismaRawQuery.mock.calls[0]; + + expect(query).toContain('WITH prefix AS'); + expect(query).toContain('visit_stats as'); + expect(query).toContain('sum(visit_stats.c) as "pageviews"'); + expect(query).toContain('count(distinct visit_stats.session_id) as "visitors"'); + expect(query).toContain('count(distinct visit_stats.visit_id) as "visits"'); + expect(query).toContain('sum(case when visit_stats.c = 1 then 1 else 0 end) as "bounces"'); + // getTimestampDiffSQL output is injected into the totaltime column + expect(query).toContain('sum(ts_diff(min_time, max_time)) as "totaltime"'); + expect(getTimestampDiffSQL).toHaveBeenCalledWith( + 'visit_stats.min_time', + 'visit_stats.max_time', + ); + expect(query).toContain(parseFiltersResult.filterQuery); + expect(query).toContain(parseFiltersResult.cohortQuery); + expect(query).toContain(parseFiltersResult.excludeBounceQuery); + expect(query).toContain(parseFiltersResult.joinSessionQuery); + expect(params).toBe(parseFiltersResult.queryParams); + expect(tag).toBe('getChannelExpandedMetrics'); + }); + + test('toPostgresPositionClause renders one ilike per array entry', async () => { + await getChannelExpandedMetrics('website-1', {}); + const [query] = prismaRawQuery.mock.calls[0]; + + for (const val of PAID_AD_PARAMS) { + expect(query).toContain(`url_query ilike '%${val}%'`); + } + for (const domainList of [ + LLM_DOMAINS, + SEARCH_DOMAINS, + SOCIAL_DOMAINS, + EMAIL_DOMAINS, + SHOPPING_DOMAINS, + VIDEO_DOMAINS, + ]) { + for (const val of domainList) { + expect(query).toContain(`referrer_domain ilike '%${val}%'`); + } + } + }); +}); + +describe('getChannelExpandedMetrics clickhouse branch', () => { + beforeEach(() => { + state.mode = 'clickhouse'; + }); + + test('builds the multiSearchAny aggregation query', async () => { + await getChannelExpandedMetrics('website-1', {}); + + expect(clickhouseRawQuery).toHaveBeenCalledTimes(1); + expect(prismaRawQuery).not.toHaveBeenCalled(); + const [query, params, tag] = clickhouseRawQuery.mock.calls[0]; + + expect(query).toContain('uniq(t.session_id) as "visitors"'); + expect(query).toContain('uniq(t.visit_id) as "visits"'); + expect(query).toContain('sum(if(t.c = 1, 1, 0)) as "bounces"'); + expect(query).toContain('sum(max_time-min_time) as "totaltime"'); + expect(query).toContain('where website_id = {websiteId:UUID}'); + expect(query).toContain( + 'and created_at between {startDate:DateTime64} and {endDate:DateTime64}', + ); + expect(query).toContain(parseFiltersResult.filterQuery); + expect(query).toContain(parseFiltersResult.cohortQuery); + expect(query).toContain(parseFiltersResult.excludeBounceQuery); + expect(params).toBe(parseFiltersResult.queryParams); + expect(tag).toBe('getChannelExpandedMetrics'); + // clickhouse expanded query does not use getTimestampDiffSQL or joinSessionQuery + expect(getTimestampDiffSQL).not.toHaveBeenCalled(); + expect(query).not.toContain(parseFiltersResult.joinSessionQuery); + }); + + test('toClickHouseStringArray quotes and comma-joins each entry', async () => { + await getChannelExpandedMetrics('website-1', {}); + const [query] = clickhouseRawQuery.mock.calls[0]; + + expect(query).toContain( + `multiSearchAny(lower(url_query), [${PAID_AD_PARAMS.map(v => `'${v}'`).join(', ')}])`, + ); + expect(query).toContain( + `multiSearchAny(lower(referrer_domain), [${SOCIAL_DOMAINS.map(v => `'${v}'`).join(', ')}])`, + ); + }); +}); diff --git a/src/queries/sql/getChannelMetrics.test.ts b/src/queries/sql/getChannelMetrics.test.ts new file mode 100644 index 000000000..c5367dfca --- /dev/null +++ b/src/queries/sql/getChannelMetrics.test.ts @@ -0,0 +1,208 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { + EMAIL_DOMAINS, + LLM_DOMAINS, + PAID_AD_PARAMS, + SEARCH_DOMAINS, + SHOPPING_DOMAINS, + SOCIAL_DOMAINS, + VIDEO_DOMAINS, +} from '@/lib/constants'; +import { getChannelMetrics } from './getChannelMetrics'; + +const { state, prismaRawQuery, prismaParseFilters, clickhouseRawQuery, clickhouseParseFilters } = + vi.hoisted(() => ({ + state: { mode: 'prisma' as 'prisma' | 'clickhouse' }, + prismaRawQuery: vi.fn(), + prismaParseFilters: vi.fn(), + clickhouseRawQuery: vi.fn(), + clickhouseParseFilters: vi.fn(), + })); + +vi.mock('@/lib/db', () => ({ + CLICKHOUSE: 'clickhouse', + PRISMA: 'prisma', + runQuery: vi.fn((queries: Record unknown>) => queries[state.mode]()), +})); + +vi.mock('@/lib/prisma', () => ({ + default: { + rawQuery: prismaRawQuery, + parseFilters: prismaParseFilters, + }, +})); + +vi.mock('@/lib/clickhouse', () => ({ + default: { + rawQuery: clickhouseRawQuery, + parseFilters: clickhouseParseFilters, + }, +})); + +const parseFiltersResult = { + queryParams: { websiteId: 'website-1' }, + filterQuery: 'and website_event.utm_source = {{utmSource}}', + joinSessionQuery: 'join session on session.session_id = website_event.session_id', + cohortQuery: 'join cohort on cohort.session_id = website_event.session_id', + excludeBounceQuery: 'and website_event.visit_id in (select visit_id from bounces)', + dateQuery: 'and created_at between {{startDate}} and {{endDate}}', +}; + +beforeEach(() => { + state.mode = 'prisma'; + prismaRawQuery.mockReset(); + prismaParseFilters.mockReset(); + clickhouseRawQuery.mockReset(); + clickhouseParseFilters.mockReset(); + + prismaParseFilters.mockReturnValue(parseFiltersResult); + clickhouseParseFilters.mockReturnValue(parseFiltersResult); + prismaRawQuery.mockResolvedValue([]); + clickhouseRawQuery.mockResolvedValue([]); +}); + +describe('getChannelMetrics postgres branch', () => { + test('builds the channel classification CTE with parseFilters fragments injected', async () => { + await getChannelMetrics('website-1', {}); + + expect(prismaRawQuery).toHaveBeenCalledTimes(1); + const [query, params, tag] = prismaRawQuery.mock.calls[0]; + + expect(query).toContain('WITH prefix AS'); + expect(query).toContain('channels as'); + expect(query).toContain('visit_channels as'); + expect(query).toContain('where website_event.website_id = {{websiteId::uuid}}'); + expect(query).toContain('and website_event.event_type NOT IN (2, 5)'); + // parseFilters fragments are interpolated verbatim + expect(query).toContain(parseFiltersResult.filterQuery); + expect(query).toContain(parseFiltersResult.cohortQuery); + expect(query).toContain(parseFiltersResult.excludeBounceQuery); + expect(query).toContain(parseFiltersResult.joinSessionQuery); + expect(query).toContain(parseFiltersResult.dateQuery); + expect(params).toBe(parseFiltersResult.queryParams); + expect(tag).toBe('getChannelMetrics'); + }); + + test('emits the direct/paidAds/referral CASE branches in order', async () => { + await getChannelMetrics('website-1', {}); + const [query] = prismaRawQuery.mock.calls[0]; + + expect(query).toContain("when referrer_domain = '' and url_query = '' then 'direct'"); + expect(query).toContain("then 'paidAds'"); + expect(query).toContain("utm_medium ilike '%affiliate%' then 'affiliate'"); + expect(query).toContain("utm_medium ilike '%sms%' or utm_source ilike '%sms%' then 'sms'"); + expect(query).toContain("concat(prefix, 'Search')"); + expect(query).toContain("concat(prefix, 'Social')"); + expect(query).toContain("concat(prefix, 'Shopping')"); + expect(query).toContain("concat(prefix, 'Video')"); + }); + + test('toPostgresLikeClause renders one ilike per array entry joined by OR', async () => { + await getChannelMetrics('website-1', {}); + const [query] = prismaRawQuery.mock.calls[0]; + + for (const val of PAID_AD_PARAMS) { + expect(query).toContain(`url_query ilike '%${val}%'`); + } + for (const val of SEARCH_DOMAINS) { + expect(query).toContain(`referrer_domain ilike '%${val}%'`); + } + for (const domainList of [ + LLM_DOMAINS, + SOCIAL_DOMAINS, + EMAIL_DOMAINS, + SHOPPING_DOMAINS, + VIDEO_DOMAINS, + ]) { + for (const val of domainList) { + expect(query).toContain(`referrer_domain ilike '%${val}%'`); + } + } + // referral clause uses a literal array, not a constant + expect(query).toContain("utm_medium ilike '%referral%'"); + expect(query).toContain("utm_medium ilike '%app%'"); + expect(query).toContain("utm_medium ilike '%link%'"); + }); + + test('coerces the y column to a number in results', async () => { + prismaRawQuery.mockResolvedValue([{ x: 'direct', y: '42' }]); + + const result = await getChannelMetrics('website-1', {}); + + expect(result).toEqual([{ x: 'direct', y: 42 }]); + }); + + // NOTE: toPostgresLikeClause only escapes single quotes (' -> ''). LIKE + // wildcards (% and _) and backslashes pass through unescaped. This is safe + // today because the helper is private and only ever receives the hardcoded + // constant arrays, but it would be an escaping gap if user input reached it. + // See getChannelMetrics.ts:180-182. + test('single-quote escaping doubles quotes (documenting current helper behavior)', async () => { + // None of the real constants contain quotes, so assert the round-trip of + // the escaping regex against every constant value: no bare `''` artifacts + // should appear inside a domain fragment. + await getChannelMetrics('website-1', {}); + const [query] = prismaRawQuery.mock.calls[0]; + + for (const val of [...PAID_AD_PARAMS, ...LLM_DOMAINS, ...SEARCH_DOMAINS]) { + const escaped = val.replace(/'/g, "''"); + expect(query).toContain(`'%${escaped}%'`); + } + }); +}); + +describe('getChannelMetrics clickhouse branch', () => { + beforeEach(() => { + state.mode = 'clickhouse'; + }); + + test('builds the multiSearchAny channel query with parseFilters fragments', async () => { + await getChannelMetrics('website-1', {}); + + expect(clickhouseRawQuery).toHaveBeenCalledTimes(1); + expect(prismaRawQuery).not.toHaveBeenCalled(); + const [query, params, tag] = clickhouseRawQuery.mock.calls[0]; + + expect(query).toContain('WITH channels as'); + expect(query).toContain('where website_id = {websiteId:UUID}'); + expect(query).toContain('and event_type NOT IN (2, 5)'); + expect(query).toContain( + "multiSearchAny(lower(utm_medium), ['cp', 'ppc', 'retargeting', 'paid'])", + ); + expect(query).toContain(parseFiltersResult.filterQuery); + expect(query).toContain(parseFiltersResult.cohortQuery); + expect(query).toContain(parseFiltersResult.excludeBounceQuery); + expect(query).toContain(parseFiltersResult.dateQuery); + expect(params).toBe(parseFiltersResult.queryParams); + expect(tag).toBe('getChannelMetrics'); + // clickhouse branch does not join a session table + expect(query).not.toContain(parseFiltersResult.joinSessionQuery); + }); + + test('toClickHouseStringArray quotes and comma-joins each entry', async () => { + await getChannelMetrics('website-1', {}); + const [query] = clickhouseRawQuery.mock.calls[0]; + + expect(query).toContain( + `multiSearchAny(lower(url_query), [${PAID_AD_PARAMS.map(v => `'${v}'`).join(', ')}])`, + ); + expect(query).toContain( + `multiSearchAny(lower(referrer_domain), [${LLM_DOMAINS.map(v => `'${v}'`).join(', ')}])`, + ); + expect(query).toContain( + `multiSearchAny(lower(referrer_domain), [${SEARCH_DOMAINS.map(v => `'${v}'`).join(', ')}])`, + ); + }); + + // NOTE: toClickHouseStringArray escapes single quotes as \' but leaves + // backslashes and other metacharacters untouched (getChannelMetrics.ts:176-178). + // Safe today (constants only), documented here as current behavior. + test('clickhouse array escaping only handles single quotes', async () => { + await getChannelMetrics('website-1', {}); + const [query] = clickhouseRawQuery.mock.calls[0]; + + for (const val of [...VIDEO_DOMAINS, ...SHOPPING_DOMAINS]) { + expect(query).toContain(`'${val.replace(/'/g, "\\'")}'`); + } + }); +}); diff --git a/src/queries/sql/reports/getAttribution.test.ts b/src/queries/sql/reports/getAttribution.test.ts new file mode 100644 index 000000000..e843badae --- /dev/null +++ b/src/queries/sql/reports/getAttribution.test.ts @@ -0,0 +1,182 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { getAttribution } from './getAttribution'; + +const { state, prismaRawQuery, prismaParseFilters, clickhouseRawQuery, clickhouseParseFilters } = + vi.hoisted(() => ({ + state: { mode: 'prisma' as 'prisma' | 'clickhouse' }, + prismaRawQuery: vi.fn(), + prismaParseFilters: vi.fn(), + clickhouseRawQuery: vi.fn(), + clickhouseParseFilters: vi.fn(), + })); + +vi.mock('@/lib/db', () => ({ + CLICKHOUSE: 'clickhouse', + PRISMA: 'prisma', + runQuery: vi.fn((queries: Record unknown>) => queries[state.mode]()), +})); + +vi.mock('@/lib/prisma', () => ({ + default: { + rawQuery: prismaRawQuery, + parseFilters: prismaParseFilters, + }, +})); + +vi.mock('@/lib/clickhouse', () => ({ + default: { + rawQuery: clickhouseRawQuery, + parseFilters: clickhouseParseFilters, + }, +})); + +const parseFiltersResult = { + queryParams: { websiteId: 'website-1' }, + filterQuery: 'and website_event.utm_source = {{utmSource}}', + joinSessionQuery: 'join session on session.session_id = website_event.session_id', + cohortQuery: 'join cohort on cohort.session_id = website_event.session_id', +}; + +const parameters = { + startDate: new Date('2026-05-18T00:00:00.000Z'), + endDate: new Date('2026-05-19T00:00:00.000Z'), + model: 'last-click', + type: 'path', + step: '/checkout', +}; + +beforeEach(() => { + state.mode = 'prisma'; + prismaRawQuery.mockReset(); + prismaParseFilters.mockReset(); + clickhouseRawQuery.mockReset(); + clickhouseParseFilters.mockReset(); + + prismaParseFilters.mockReturnValue(parseFiltersResult); + clickhouseParseFilters.mockReturnValue(parseFiltersResult); + prismaRawQuery.mockResolvedValue([]); + clickhouseRawQuery.mockResolvedValue([]); +}); + +describe('getAttribution postgres branch', () => { + test('runs eight rawQuery calls covering referrer, paid ads, utm columns and totals', async () => { + await getAttribution('website-1', parameters, {}); + + expect(prismaRawQuery).toHaveBeenCalledTimes(8); + expect(clickhouseRawQuery).not.toHaveBeenCalled(); + + const queries = prismaRawQuery.mock.calls.map(c => c[0] as string); + expect(queries.some(q => q.includes('we.referrer_domain'))).toBe(true); + expect(queries.some(q => q.includes("then 'Google Ads'"))).toBe(true); + expect(queries.some(q => q.includes('we.utm_source'))).toBe(true); + expect(queries.some(q => q.includes('we.utm_medium'))).toBe(true); + expect(queries.some(q => q.includes('we.utm_campaign'))).toBe(true); + expect(queries.some(q => q.includes('we.utm_content'))).toBe(true); + expect(queries.some(q => q.includes('we.utm_term'))).toBe(true); + }); + + test('type=path selects url_path column and uses the step param', async () => { + await getAttribution('website-1', parameters, {}); + + // eventType / column derive from `type`; column is embedded in the SQL string + const eventQueryCall = prismaRawQuery.mock.calls.find(c => + (c[0] as string).includes('WITH events AS'), + ); + expect(eventQueryCall?.[0]).toContain('website_event.url_path = {{step}}'); + expect(prismaParseFilters).toHaveBeenCalledWith( + expect.objectContaining({ websiteId: 'website-1', eventType: 1 }), + ); + }); + + test('type=event selects event_name column and customEvent event type', async () => { + await getAttribution('website-1', { ...parameters, type: 'event' }, {}); + + const eventQueryCall = prismaRawQuery.mock.calls.find(c => + (c[0] as string).includes('WITH events AS'), + ); + expect(eventQueryCall?.[0]).toContain('website_event.event_name = {{step}}'); + expect(prismaParseFilters).toHaveBeenCalledWith(expect.objectContaining({ eventType: 2 })); + }); + + test('first-click model uses min(created_at) with no upper-bound clause', async () => { + await getAttribution('website-1', { ...parameters, model: 'first-click' }, {}); + + const referrerQuery = prismaRawQuery.mock.calls + .map(c => c[0] as string) + .find(q => q.includes('we.referrer_domain') && q.includes('model AS')); + expect(referrerQuery).toContain('min(we.created_at) created_at'); + expect(referrerQuery).not.toContain('and we.created_at < e.max_dt'); + }); + + test('last-click model uses max(created_at) bounded by e.max_dt', async () => { + await getAttribution('website-1', { ...parameters, model: 'last-click' }, {}); + + const referrerQuery = prismaRawQuery.mock.calls + .map(c => c[0] as string) + .find(q => q.includes('we.referrer_domain') && q.includes('model AS')); + expect(referrerQuery).toContain('max(we.created_at) created_at'); + expect(referrerQuery).toContain('and we.created_at < e.max_dt'); + }); + + test('returns the shaped attribution result object', async () => { + prismaRawQuery + .mockResolvedValueOnce([{ name: 'a', value: 1 }]) // referrer + .mockResolvedValueOnce([{ name: 'Google Ads', value: 2 }]) // paidAds + .mockResolvedValueOnce([{ name: 's', value: 3 }]) // source + .mockResolvedValueOnce([{ name: 'm', value: 4 }]) // medium + .mockResolvedValueOnce([{ name: 'c', value: 5 }]) // campaign + .mockResolvedValueOnce([{ name: 'ct', value: 6 }]) // content + .mockResolvedValueOnce([{ name: 't', value: 7 }]) // term + .mockResolvedValueOnce([{ pageviews: 10, visitors: 8, visits: 9 }]); // total + + const result = await getAttribution('website-1', parameters, {}); + + expect(result.referrer).toEqual([{ name: 'a', value: 1 }]); + expect(result.paidAds).toEqual([{ name: 'Google Ads', value: 2 }]); + expect(result.utm_source).toEqual([{ name: 's', value: 3 }]); + expect(result.total).toEqual({ pageviews: 10, visitors: 8, visits: 9 }); + }); +}); + +describe('getAttribution clickhouse branch', () => { + beforeEach(() => { + state.mode = 'clickhouse'; + }); + + test('runs eight rawQuery calls with clickhouse-typed params', async () => { + await getAttribution('website-1', parameters, {}); + + expect(clickhouseRawQuery).toHaveBeenCalledTimes(8); + expect(prismaRawQuery).not.toHaveBeenCalled(); + + const queries = clickhouseRawQuery.mock.calls.map(c => c[0] as string); + expect(queries.some(q => q.includes('where website_id = {websiteId:UUID}'))).toBe(true); + expect( + queries.some(q => + q.includes('created_at between {startDate:DateTime64} and {endDate:DateTime64}'), + ), + ).toBe(true); + // paid ads use multiIf in clickhouse rather than a CASE expression + expect(queries.some(q => q.includes("multiIf(gclid != '', 'Google Ads'"))).toBe(true); + }); + + test('type=path uses url_path with {step:String} and pageView event type', async () => { + await getAttribution('website-1', parameters, {}); + + const eventQuery = clickhouseRawQuery.mock.calls + .map(c => c[0] as string) + .find(q => q.includes('WITH events AS')); + expect(eventQuery).toContain('url_path = {step:String}'); + expect(clickhouseParseFilters).toHaveBeenCalledWith(expect.objectContaining({ eventType: 1 })); + }); + + test('first-click model uses min(created_at) with no upper bound', async () => { + await getAttribution('website-1', { ...parameters, model: 'first-click' }, {}); + + const referrerQuery = clickhouseRawQuery.mock.calls + .map(c => c[0] as string) + .find(q => q.includes('we.referrer_domain') && q.includes('model AS')); + expect(referrerQuery).toContain('min(we.created_at) created_at'); + expect(referrerQuery).not.toContain('where we.created_at < e.max_dt'); + }); +}); diff --git a/src/queries/sql/reports/getFunnel.test.ts b/src/queries/sql/reports/getFunnel.test.ts new file mode 100644 index 000000000..7219a50f0 --- /dev/null +++ b/src/queries/sql/reports/getFunnel.test.ts @@ -0,0 +1,260 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { getFunnel } from './getFunnel'; + +const { + state, + prismaRawQuery, + prismaParseFilters, + getAddIntervalQuery, + clickhouseRawQuery, + clickhouseParseFilters, +} = vi.hoisted(() => ({ + state: { mode: 'prisma' as 'prisma' | 'clickhouse' }, + prismaRawQuery: vi.fn(), + prismaParseFilters: vi.fn(), + getAddIntervalQuery: vi.fn(), + clickhouseRawQuery: vi.fn(), + clickhouseParseFilters: vi.fn(), +})); + +vi.mock('@/lib/db', () => ({ + CLICKHOUSE: 'clickhouse', + PRISMA: 'prisma', + runQuery: vi.fn((queries: Record unknown>) => queries[state.mode]()), +})); + +vi.mock('@/lib/prisma', () => ({ + default: { + rawQuery: prismaRawQuery, + parseFilters: prismaParseFilters, + getAddIntervalQuery, + }, +})); + +vi.mock('@/lib/clickhouse', () => ({ + default: { + rawQuery: clickhouseRawQuery, + parseFilters: clickhouseParseFilters, + }, +})); + +const parseFiltersResult = { + queryParams: { websiteId: 'website-1' }, + filterQuery: 'and website_event.utm_source = {{utmSource}}', + joinSessionQuery: 'join session on session.session_id = website_event.session_id', + cohortQuery: 'join cohort on cohort.session_id = website_event.session_id', +}; + +const baseParameters = { + startDate: new Date('2026-05-18T00:00:00.000Z'), + endDate: new Date('2026-05-19T00:00:00.000Z'), + window: 30, + steps: [ + { type: 'path', value: '/home' }, + { type: 'path', value: '/checkout' }, + ], +}; + +beforeEach(() => { + state.mode = 'prisma'; + prismaRawQuery.mockReset(); + prismaParseFilters.mockReset(); + getAddIntervalQuery.mockReset(); + clickhouseRawQuery.mockReset(); + clickhouseParseFilters.mockReset(); + + prismaParseFilters.mockReturnValue(parseFiltersResult); + clickhouseParseFilters.mockReturnValue(parseFiltersResult); + getAddIntervalQuery.mockReturnValue('add_interval(l.created_at, 30 minute)'); + // formatResults reads results[0].count etc, so provide numeric counts + prismaRawQuery.mockResolvedValue([{ count: 100 }, { count: 40 }]); + clickhouseRawQuery.mockResolvedValue([{ count: 100 }, { count: 40 }]); +}); + +describe('getFunnel postgres branch', () => { + test('builds level CTEs and a UNION sum query, one per step', async () => { + await getFunnel('website-1', baseParameters, {}); + + expect(prismaRawQuery).toHaveBeenCalledTimes(1); + const [query, params] = prismaRawQuery.mock.calls[0]; + + expect(query).toContain('WITH level1 AS'); + expect(query).toContain(', level2 AS'); + expect(query).toContain('select 1 as level, count(distinct(session_id)) as count from level1'); + expect(query).toContain('union '); + expect(query).toContain('select 2 as level, count(distinct(session_id)) as count from level2'); + expect(query).toContain('ORDER BY level'); + // step values become positional params keyed by index + expect(params).toMatchObject({ 0: '/home', 1: '/checkout' }); + // merged with parseFilters queryParams + expect(params).toMatchObject({ websiteId: 'website-1' }); + // level1 filters use equality against {{0}}; level2 against {{1}} + expect(query).toContain('and url_path = {{0}}'); + expect(query).toContain('and we.url_path = {{1}}'); + // level2 window uses getAddIntervalQuery output + expect(query).toContain('add_interval(l.created_at, 30 minute)'); + expect(getAddIntervalQuery).toHaveBeenCalledWith('l.created_at ', '30 minute'); + }); + + test('wildcard step values switch to LIKE and translate * to %', async () => { + await getFunnel( + 'website-1', + { + ...baseParameters, + steps: [ + { type: 'path', value: '/blog/*' }, + { type: 'path', value: '*/thanks' }, + ], + }, + {}, + ); + + const [query, params] = prismaRawQuery.mock.calls[0]; + expect(query).toContain('and url_path like {{0}}'); + expect(query).toContain('and we.url_path like {{1}}'); + expect(params).toMatchObject({ 0: '/blog/%', 1: '%/thanks' }); + }); + + test('event steps with contains filter emit exists() subqueries and ilike params', async () => { + await getFunnel( + 'website-1', + { + ...baseParameters, + steps: [ + { + type: 'event', + value: 'purchase', + filters: [{ property: 'plan', operator: 'c', value: 'pro' }], + }, + { type: 'event', value: 'confirm' }, + ], + }, + {}, + ); + + const [query, params] = prismaRawQuery.mock.calls[0]; + expect(query).toContain('and event_name = {{0}}'); + expect(query).toContain('and exists ('); + expect(query).toContain('_ed0_0.data_key = {{f_0_0_k}}'); + expect(query).toContain('ilike {{f_0_0_v}}'); + // contains -> ilike operator, value wrapped in % ... % + expect(params).toMatchObject({ f_0_0_k: 'plan', f_0_0_v: '%pro%' }); + }); + + test('filter operators map to the expected SQL operators', async () => { + await getFunnel( + 'website-1', + { + ...baseParameters, + steps: [ + { + type: 'event', + value: 'e', + filters: [ + { property: 'a', operator: 'eq', value: 'x' }, + { property: 'b', operator: 'neq', value: 'y' }, + { property: 'c', operator: 'dnc', value: 'z' }, + ], + }, + { type: 'event', value: 'f' }, + ], + }, + {}, + ); + + const [query, params] = prismaRawQuery.mock.calls[0]; + // eq -> '=' with raw value + expect(query).toContain('= {{f_0_0_v}}'); + expect(params).toMatchObject({ f_0_0_v: 'x' }); + // neq -> '!=' with raw value + expect(query).toContain('!= {{f_0_1_v}}'); + expect(params).toMatchObject({ f_0_1_v: 'y' }); + // dnc -> 'not ilike' with wrapped value + expect(query).toContain('not ilike {{f_0_2_v}}'); + expect(params).toMatchObject({ f_0_2_v: '%z%' }); + }); + + test('formatResults computes dropoff / remaining relative to first step', async () => { + prismaRawQuery.mockResolvedValue([{ count: 100 }, { count: 40 }]); + + const result = await getFunnel('website-1', baseParameters, {}); + + expect(result[0]).toMatchObject({ visitors: 100, previous: 0, dropped: 0, remaining: 1 }); + expect(result[1]).toMatchObject({ + visitors: 40, + previous: 100, + dropped: 60, + remaining: 0.4, + }); + expect(result[1].dropoff).toBeCloseTo(0.6); + }); +}); + +describe('getFunnel clickhouse branch', () => { + beforeEach(() => { + state.mode = 'clickhouse'; + }); + + test('builds level0 base CTE, step filter predicate and UNION ALL sum query', async () => { + await getFunnel('website-1', baseParameters, {}); + + expect(clickhouseRawQuery).toHaveBeenCalledTimes(1); + expect(prismaRawQuery).not.toHaveBeenCalled(); + const [query, params] = clickhouseRawQuery.mock.calls[0]; + + expect(query).toContain('WITH level0 AS'); + expect(query).toContain('level1 AS'); + expect(query).toContain(', level2 AS'); + expect(query).toContain('union all '); + expect(query).toContain('and website_id = {websiteId:UUID}'); + // step filter predicate uses param0/param1 + expect(query).toContain('url_path = {param0:String}'); + expect(query).toContain('y.url_path = {param1:String}'); + // window interval expressed inline + expect(query).toContain('x.created_at + interval 30 minute'); + expect(params).toMatchObject({ param0: '/home', param1: '/checkout', websiteId: 'website-1' }); + }); + + test('wildcard values switch to LIKE with % substitution', async () => { + await getFunnel( + 'website-1', + { + ...baseParameters, + steps: [ + { type: 'path', value: '/a/*' }, + { type: 'path', value: '/b' }, + ], + }, + {}, + ); + + const [query, params] = clickhouseRawQuery.mock.calls[0]; + expect(query).toContain('url_path like {param0:String}'); + expect(params).toMatchObject({ param0: '/a/%', param1: '/b' }); + }); + + test('event data filters emit event_id IN subqueries with typed params', async () => { + await getFunnel( + 'website-1', + { + ...baseParameters, + steps: [ + { + type: 'event', + value: 'purchase', + filters: [{ property: 'plan', operator: 'c', value: 'pro' }], + }, + { type: 'event', value: 'confirm' }, + ], + }, + {}, + ); + + const [query, params] = clickhouseRawQuery.mock.calls[0]; + // first step uses level0 alias for event_id + expect(query).toContain('and level0.event_id in ('); + expect(query).toContain('data_key = {f_0_0_k:String}'); + expect(query).toContain('like {f_0_0_v:String}'); + expect(params).toMatchObject({ f_0_0_k: 'plan', f_0_0_v: '%pro%' }); + }); +}); diff --git a/src/store/app.test.ts b/src/store/app.test.ts new file mode 100644 index 000000000..dcefd9a53 --- /dev/null +++ b/src/store/app.test.ts @@ -0,0 +1,79 @@ +import { beforeEach, describe, expect, test } from 'vitest'; +import { + setConfig, + setDateRangeValue, + setLocale, + setShareData, + setTimezone, + setUser, + useApp, +} from './app'; + +const initialState = { ...useApp.getState() }; + +beforeEach(() => { + useApp.setState({ ...initialState }, true); +}); + +describe('setTimezone', () => { + test('updates the timezone', () => { + setTimezone('America/New_York'); + expect(useApp.getState().timezone).toBe('America/New_York'); + }); + + test('leaves other fields untouched', () => { + setTimezone('UTC'); + expect(useApp.getState().locale).toBe(initialState.locale); + }); +}); + +describe('setLocale', () => { + test('updates the locale', () => { + setLocale('fr-FR'); + expect(useApp.getState().locale).toBe('fr-FR'); + }); +}); + +describe('setShareData', () => { + test('sets both share and shareToken', () => { + const share = { id: 'share-1' }; + const shareToken = { token: 'abc' }; + setShareData(share, shareToken); + + const state = useApp.getState(); + expect(state.share).toBe(share); + expect(state.shareToken).toBe(shareToken); + }); + + test('clears share data with nulls', () => { + setShareData({ id: 'share-1' }, { token: 'abc' }); + setShareData(null, null); + + const state = useApp.getState(); + expect(state.share).toBeNull(); + expect(state.shareToken).toBeNull(); + }); +}); + +describe('setUser', () => { + test('sets the user object', () => { + const user = { id: 'user-1' }; + setUser(user); + expect(useApp.getState().user).toBe(user); + }); +}); + +describe('setConfig', () => { + test('sets the config object', () => { + const config = { cloudMode: true }; + setConfig(config); + expect(useApp.getState().config).toBe(config); + }); +}); + +describe('setDateRangeValue', () => { + test('updates the date range value', () => { + setDateRangeValue('7day'); + expect(useApp.getState().dateRangeValue).toBe('7day'); + }); +}); diff --git a/src/store/cache.test.ts b/src/store/cache.test.ts new file mode 100644 index 000000000..1c3b10333 --- /dev/null +++ b/src/store/cache.test.ts @@ -0,0 +1,33 @@ +import { beforeEach, describe, expect, test } from 'vitest'; +import { setValue, useCache } from './cache'; + +beforeEach(() => { + useCache.setState({}, true); +}); + +describe('setValue', () => { + test('sets a value under the given key', () => { + setValue('websites', 5); + expect((useCache.getState() as any).websites).toBe(5); + }); + + test('merges additional keys without dropping existing ones', () => { + setValue('a', 1); + setValue('b', 2); + + const state = useCache.getState() as any; + expect(state.a).toBe(1); + expect(state.b).toBe(2); + }); + + test('overwrites an existing key', () => { + setValue('a', 1); + setValue('a', 2); + expect((useCache.getState() as any).a).toBe(2); + }); + + test('supports object values', () => { + setValue('report', { id: 'r1' }); + expect((useCache.getState() as any).report).toEqual({ id: 'r1' }); + }); +}); diff --git a/src/store/dashboard.test.ts b/src/store/dashboard.test.ts new file mode 100644 index 000000000..0985dc77a --- /dev/null +++ b/src/store/dashboard.test.ts @@ -0,0 +1,32 @@ +import { beforeEach, describe, expect, test } from 'vitest'; +import { initialState, saveDashboard, useDashboard } from './dashboard'; + +beforeEach(() => { + useDashboard.setState({ ...initialState }, true); +}); + +describe('saveDashboard', () => { + test('merges the provided settings into state', () => { + saveDashboard({ editing: true }); + + const state = useDashboard.getState() as any; + expect(state.editing).toBe(true); + expect(state.showCharts).toBe(initialState.showCharts); + }); + + test('updates multiple fields at once', () => { + saveDashboard({ limit: 20, isEdited: true, websiteOrder: ['a', 'b'] }); + + const state = useDashboard.getState() as any; + expect(state.limit).toBe(20); + expect(state.isEdited).toBe(true); + expect(state.websiteOrder).toEqual(['a', 'b']); + }); + + test('persists the resulting state to storage', () => { + saveDashboard({ editing: true }); + + const stored = JSON.parse(localStorage.getItem('umami.dashboard') as string); + expect(stored.editing).toBe(true); + }); +}); diff --git a/src/store/version.test.ts b/src/store/version.test.ts new file mode 100644 index 000000000..984e12af8 --- /dev/null +++ b/src/store/version.test.ts @@ -0,0 +1,81 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { getItem } from '@/lib/storage'; +import { checkVersion, useVersion } from './version'; + +vi.mock('@/lib/storage', () => ({ + getItem: vi.fn(), +})); + +const initialState = { + current: '1.0.0', + latest: null, + hasUpdate: false, + checked: false, + releaseUrl: null, +}; + +function mockFetch(response: { ok: boolean; body?: any }) { + return vi.fn().mockResolvedValue({ + ok: response.ok, + json: async () => response.body, + }); +} + +beforeEach(() => { + useVersion.setState({ ...initialState }, true); + vi.mocked(getItem).mockReset(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('checkVersion', () => { + test('does not update state when the request fails', async () => { + vi.stubGlobal('fetch', mockFetch({ ok: false })); + + await checkVersion(); + + const state = useVersion.getState(); + expect(state.checked).toBe(false); + expect(state.latest).toBeNull(); + }); + + test('marks an update available when latest is newer', async () => { + vi.mocked(getItem).mockReturnValue(null); + vi.stubGlobal( + 'fetch', + mockFetch({ ok: true, body: { latest: '2.0.0', url: 'https://release' } }), + ); + + await checkVersion(); + + const state = useVersion.getState(); + expect(state.checked).toBe(true); + expect(state.latest).toBe('2.0.0'); + expect(state.hasUpdate).toBe(true); + expect(state.releaseUrl).toBe('https://release'); + }); + + test('does not flag an update when latest is not newer', async () => { + vi.mocked(getItem).mockReturnValue(null); + vi.stubGlobal('fetch', mockFetch({ ok: true, body: { latest: '0.9.0', url: 'x' } })); + + await checkVersion(); + + const state = useVersion.getState(); + expect(state.checked).toBe(true); + expect(state.hasUpdate).toBe(false); + }); + + test('does not flag an update when the latest version was already dismissed', async () => { + vi.mocked(getItem).mockReturnValue({ version: '2.0.0' }); + vi.stubGlobal('fetch', mockFetch({ ok: true, body: { latest: '2.0.0', url: 'x' } })); + + await checkVersion(); + + const state = useVersion.getState(); + expect(state.checked).toBe(true); + expect(state.hasUpdate).toBe(false); + }); +}); diff --git a/src/store/websites.test.ts b/src/store/websites.test.ts new file mode 100644 index 000000000..5ad7e2e28 --- /dev/null +++ b/src/store/websites.test.ts @@ -0,0 +1,91 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { setWebsiteDateCompare, setWebsiteDateRange, useWebsites } from './websites'; + +beforeEach(() => { + useWebsites.setState({}, true); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('setWebsiteDateRange', () => { + test('creates a nested entry for a new website id', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')); + + const range = { + startDate: new Date('2026-01-01'), + endDate: new Date('2026-01-31'), + value: '30day', + }; + setWebsiteDateRange('website-1', range as any); + + const state = useWebsites.getState() as any; + expect(state['website-1'].dateRange).toEqual({ ...range, modified: Date.now() }); + }); + + test('stamps modified with the current time', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-05T12:00:00.000Z')); + + setWebsiteDateRange('website-1', { value: '7day' } as any); + + const state = useWebsites.getState() as any; + expect(state['website-1'].dateRange.modified).toBe( + new Date('2026-05-05T12:00:00.000Z').getTime(), + ); + }); + + test('preserves an existing dateCompare when updating the range', () => { + setWebsiteDateCompare('website-1', 'prev'); + setWebsiteDateRange('website-1', { value: '7day' } as any); + + const state = useWebsites.getState() as any; + expect(state['website-1'].dateCompare).toBe('prev'); + expect(state['website-1'].dateRange.value).toBe('7day'); + }); + + test('keeps date ranges for other websites independent', () => { + setWebsiteDateRange('website-1', { value: '7day' } as any); + setWebsiteDateRange('website-2', { value: '30day' } as any); + + const state = useWebsites.getState() as any; + expect(state['website-1'].dateRange.value).toBe('7day'); + expect(state['website-2'].dateRange.value).toBe('30day'); + }); + + test('overwrites a prior date range for the same website', () => { + setWebsiteDateRange('website-1', { value: '7day' } as any); + setWebsiteDateRange('website-1', { value: '30day' } as any); + + const state = useWebsites.getState() as any; + expect(state['website-1'].dateRange.value).toBe('30day'); + }); +}); + +describe('setWebsiteDateCompare', () => { + test('creates a nested entry for a new website id', () => { + setWebsiteDateCompare('website-1', 'prev'); + + const state = useWebsites.getState() as any; + expect(state['website-1'].dateCompare).toBe('prev'); + }); + + test('preserves an existing dateRange when updating the compare', () => { + setWebsiteDateRange('website-1', { value: '7day' } as any); + setWebsiteDateCompare('website-1', 'yoy'); + + const state = useWebsites.getState() as any; + expect(state['website-1'].dateRange.value).toBe('7day'); + expect(state['website-1'].dateCompare).toBe('yoy'); + }); + + test('overwrites a prior compare value', () => { + setWebsiteDateCompare('website-1', 'prev'); + setWebsiteDateCompare('website-1', 'yoy'); + + const state = useWebsites.getState() as any; + expect(state['website-1'].dateCompare).toBe('yoy'); + }); +});