Add coverage for send route and core permission and query logic
This commit is contained in:
1 parent
3a8f268d21
commit
8148d775b0
19 files changed
+3355
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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(', ')}])`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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%' });
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user