Files

263 lines
7.9 KiB
TypeScript

import type { ApiClient } from '../client';
import { login } from '../helpers/auth';
import { ADMIN_USER } from '../helpers/constants';
import { assertStatus } from '../helpers/entities';
import { buildDataset, CURRENCY, type Dataset, HOSTNAME } from './dataset';
import {
SEED_IDS,
SEED_LINK,
SEED_PIXEL,
SEED_SHARE,
SEED_TEAM,
SEED_USERS,
SEED_WEBSITES,
} from './ids';
import { type IngestResult, ingestDataset } from './ingest';
import type { SeedCredentials, SeedState } from './state';
const READINESS_TIMEOUT_MS = 60_000;
async function ensureUser(
admin: ApiClient,
credentials: { username: string; password: string; role: string },
): Promise<SeedCredentials> {
const created = await admin.post('/api/users', credentials);
if (created.status === 200) {
return { ...credentials, id: created.body.id };
}
if (created.status !== 400) {
throw new Error(
`Failed to create ${credentials.username} (${created.status}): ${created.text}`,
);
}
// Already exists (re-run against a kept stack): look it up.
const users = assertStatus(
await admin.get('/api/admin/users', { params: { search: credentials.username } }),
200,
'list users',
);
const user = users.body.data?.find((u: any) => u.username === credentials.username);
if (!user) {
throw new Error(`User ${credentials.username} exists but could not be found`);
}
return { ...credentials, id: user.id };
}
async function recreateWebsite(
client: ApiClient,
id: string,
data: { name: string; domain: string },
) {
const existing = await client.get(`/api/websites/${id}`);
// Admins bypass the permission check, so an unknown id yields 200 with a null body.
if (existing.status === 200 && existing.body?.id) {
assertStatus(await client.del(`/api/websites/${id}`), 200, `delete website ${id}`);
}
return assertStatus(await client.post('/api/websites', { id, ...data }), 200, 'create website')
.body;
}
async function ensureEntity(
client: ApiClient,
collection: 'links' | 'pixels',
id: string,
data: Record<string, unknown>,
) {
const existing = await client.get(`/api/${collection}/${id}`);
if (existing.status === 200 && existing.body?.id) {
return existing.body;
}
return assertStatus(
await client.post(`/api/${collection}`, { id, ...data }),
200,
`create ${collection}`,
).body;
}
async function ensureTeam(admin: ApiClient, memberId: string) {
const teams = assertStatus(await admin.get('/api/teams'), 200, 'list teams');
let team = teams.body.data?.find((t: any) => t.name === SEED_TEAM.name);
if (!team) {
const created = assertStatus(await admin.post('/api/teams', SEED_TEAM), 200, 'create team');
team = created.body[0];
}
const detail = assertStatus(await admin.get(`/api/teams/${team.id}`), 200, 'get team').body;
const member = await admin.post(`/api/teams/${team.id}/users`, {
userId: memberId,
role: 'team-member',
});
// 400 = already a member (re-run).
if (member.status !== 200 && member.status !== 400) {
throw new Error(`Failed to add team member (${member.status}): ${member.text}`);
}
return {
id: detail.id as string,
name: detail.name as string,
accessCode: detail.accessCode as string,
};
}
async function poll<T>(label: string, fn: () => Promise<T | undefined>, timeoutMs: number) {
const deadline = Date.now() + timeoutMs;
let last: string | undefined;
while (Date.now() < deadline) {
try {
const result = await fn();
if (result !== undefined) {
return result;
}
} catch (error) {
last = (error as Error).message;
}
await new Promise(resolve => setTimeout(resolve, 500));
}
throw new Error(`Timed out waiting for ${label}${last ? ` (${last})` : ''}`);
}
/**
* Waits until the analytics queries see the ingested data (ClickHouse
* materialized views are synchronous, but this guards against any lag) and
* harvests ids that specs need for path parameters.
*/
async function waitForAnalytics(admin: ApiClient, dataset: Dataset, ingested: IngestResult) {
const websiteId = SEED_IDS.website;
const range = () => ({ startAt: dataset.range.startAt, endAt: Date.now() });
await poll(
`${dataset.expected.pageviews} pageviews on ${websiteId}`,
async () => {
const response = await admin.get(`/api/websites/${websiteId}/stats`, { params: range() });
// /stats returns flat numbers (possibly as strings under Postgres bigint).
const pageviews = Number(response.body?.pageviews ?? 0);
return pageviews >= dataset.expected.pageviews ? pageviews : undefined;
},
READINESS_TIMEOUT_MS,
);
const sessionId = await poll(
'a session',
async () => {
const response = await admin.get(`/api/websites/${websiteId}/sessions`, {
params: { ...range(), pageSize: 1 },
});
return response.body?.data?.[0]?.id as string | undefined;
},
READINESS_TIMEOUT_MS,
);
const eventId = await poll(
'a custom event with data',
async () => {
const response = await admin.get(`/api/websites/${websiteId}/events`, {
params: { ...range(), pageSize: 50, eventType: 2 },
});
const event = response.body?.data?.find((e: any) => e.hasData);
return event?.id as string | undefined;
},
READINESS_TIMEOUT_MS,
);
await poll(
'the recorded replay',
async () => {
const response = await admin.get(`/api/websites/${websiteId}/replays`, {
params: range(),
});
return response.body?.data?.some((r: any) => r.id === ingested.replay.visitId)
? true
: undefined;
},
READINESS_TIMEOUT_MS,
);
return { sessionId, eventId };
}
export async function seedEnvironment(api: ApiClient): Promise<SeedState> {
const db = process.env.UMAMI_TEST_DB === 'clickhouse' ? 'clickhouse' : 'postgres';
const admin = api.bearer(await login(api, ADMIN_USER));
const user = await ensureUser(admin, SEED_USERS.user);
const viewer = await ensureUser(admin, SEED_USERS.viewer);
const userApi = api.bearer(await login(api, SEED_USERS.user));
const website = await recreateWebsite(admin, SEED_IDS.website, SEED_WEBSITES.primary);
const website2 = await recreateWebsite(userApi, SEED_IDS.website2, SEED_WEBSITES.secondary);
assertStatus(
await admin.post(`/api/websites/${website.id}`, {
replayConfig: {
replayEnabled: true,
heatmapEnabled: true,
sampleRate: 1,
heatmapSampleRate: 1,
},
}),
200,
'enable recorder',
);
const share = assertStatus(
await admin.post(`/api/websites/${website.id}/shares`, { ...SEED_SHARE, parameters: {} }),
200,
'create share',
).body;
const link = await ensureEntity(admin, 'links', SEED_IDS.link, SEED_LINK);
const pixel = await ensureEntity(admin, 'pixels', SEED_IDS.pixel, SEED_PIXEL);
const team = await ensureTeam(admin, user.id);
const dataset = buildDataset();
const ingested = await ingestDataset(api, dataset);
const { sessionId, eventId } = await waitForAnalytics(admin, dataset, ingested);
return {
db,
createdAt: new Date().toISOString(),
admin: { ...ADMIN_USER },
user,
viewer,
website: { id: website.id, name: website.name, domain: website.domain },
website2: { id: website2.id, name: website2.name, domain: website2.domain },
link: { id: link.id, name: link.name, url: link.url, slug: link.slug },
pixel: { id: pixel.id, name: pixel.name, slug: pixel.slug },
team,
share: { id: share.id, slug: share.slug },
range: dataset.range,
data: {
hostname: HOSTNAME,
currency: CURRENCY,
pages: dataset.pages,
eventNames: dataset.eventNames,
distinctIds: dataset.distinctIds,
expectedPageviews: dataset.expected.pageviews,
expectedWebsite2Pageviews: dataset.expected.website2Pageviews,
sessionId,
eventId,
replayVisitId: ingested.replay.visitId,
replaySessionId: ingested.replay.sessionId,
},
};
}