Add coverage for send route and core permission and query logic

This commit is contained in:
Mike Cao committed 2026-07-24 15:27:17 -07:00
1 parent 3a8f268d21
commit 8148d775b0
19 files changed
+3355

No files matched your search

+673
View File
@@ -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<string, any> },
{ headers }: { headers?: Record<string, string> } = {},
) {
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<string, any>;
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<string, any> = {}) {
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<string, any>;
const decoded = parseToken(body.cache, secret()) as Record<string, any>;
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<string, any>;
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<string, any>;
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();
});
});
+83
View File
@@ -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<string, string>, {
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',
]);
});
});
@@ -0,0 +1,123 @@
import { renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, test, vi } from 'vitest';
const nav: { pathname: string; query: Record<string, any> } = {
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');
});
});
+171
View File
@@ -0,0 +1,171 @@
import { renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, test, vi } from 'vitest';
const labels = new Proxy({} as Record<string, string>, {
get: (_target, prop: string) => prop,
});
const nav: { pathname: string; query: Record<string, any> } = {
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<string, string>, { 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);
});
});
+170
View File
@@ -0,0 +1,170 @@
import { renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, test, vi } from 'vitest';
const countryNames: Record<string, string> = {
US: 'United States',
GB: 'United Kingdom',
};
const languageNames: Record<string, string> = {
en: 'English',
};
const labels: Record<string, string> = {
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();
});
});
});
+139
View File
@@ -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);
});
});
+153
View File
@@ -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);
});
});
+153
View File
@@ -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);
});
});
+204
View File
@@ -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,
);
});
});
+356
View File
@@ -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);
});
});
@@ -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<string, () => 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(', ')}])`,
);
});
});
+208
View File
@@ -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<string, () => 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, "\\'")}'`);
}
});
});
@@ -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<string, () => 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');
});
});
+260
View File
@@ -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<string, () => 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%' });
});
});
+79
View File
@@ -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');
});
});
+33
View File
@@ -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' });
});
});
+32
View File
@@ -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);
});
});
+81
View File
@@ -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);
});
});
+91
View File
@@ -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');
});
});