261 lines
8.5 KiB
TypeScript
261 lines
8.5 KiB
TypeScript
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%' });
|
|
});
|
|
});
|