Files
umami/src/queries/sql/getChannelMetrics.test.ts
T

212 lines
8.1 KiB
TypeScript

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];
// LIKE wildcards/backslashes are escaped so values match literally (e.g. the
// underscores in PAID_AD_PARAMS like `ad_id=` become `ad\_id=`).
const escapeLike = (val: string) =>
val.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_').replace(/'/g, "''");
for (const val of PAID_AD_PARAMS) {
expect(query).toContain(`url_query ilike '%${escapeLike(val)}%'`);
}
for (const val of SEARCH_DOMAINS) {
expect(query).toContain(`referrer_domain ilike '%${escapeLike(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 '%${escapeLike(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 }]);
});
// toPostgresLikeClause escapes LIKE wildcards (% and _) and backslashes so the
// value matches literally, then escapes single quotes for the SQL string
// literal. The hardcoded constants contain none of these, so their rendered
// form is unchanged; this asserts each constant appears intact in the clause.
test('renders an ilike clause for each escaped constant value', async () => {
await getChannelMetrics('website-1', {});
const [query] = prismaRawQuery.mock.calls[0];
const escapeLike = (val: string) =>
val.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_').replace(/'/g, "''");
for (const val of [...PAID_AD_PARAMS, ...LLM_DOMAINS, ...SEARCH_DOMAINS]) {
expect(query).toContain(`'%${escapeLike(val)}%'`);
}
});
});
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(', ')}])`,
);
});
// toClickHouseStringArray escapes backslashes and single quotes for ClickHouse
// string literals. The constants contain neither, so they render unchanged.
test('clickhouse array escaping handles backslashes and single quotes', async () => {
await getChannelMetrics('website-1', {});
const [query] = clickhouseRawQuery.mock.calls[0];
for (const val of [...VIDEO_DOMAINS, ...SHOPPING_DOMAINS]) {
const escaped = val.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
expect(query).toContain(`'${escaped}'`);
}
});
});