mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
274 lines
9.5 KiB
TypeScript
274 lines
9.5 KiB
TypeScript
// Exercises the scoreboard queries against a real database.
|
|
//
|
|
// These are raw SQL: a date_trunc grouping, a COALESCE across two tables and a
|
|
// filtered left join. None of that is checked by the type system, so a seeded
|
|
// week with known counts is the only thing standing between a renamed column and
|
|
// a growth page that renders zeros forever.
|
|
|
|
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
|
|
import type { AcquisitionChannel, AnalyticsEventName } from '@prisma/client';
|
|
import { db } from '@/lib/db';
|
|
import {
|
|
AT_RISK_SILENT_DAYS,
|
|
getCohortComparison,
|
|
getScoreboard,
|
|
} from '@/lib/analytics/scoreboard';
|
|
import { createUser } from '../factories';
|
|
|
|
function daysAgo(days: number): Date {
|
|
const date = new Date();
|
|
date.setUTCDate(date.getUTCDate() - days);
|
|
return date;
|
|
}
|
|
|
|
/**
|
|
* The Monday this week started, in UTC.
|
|
*
|
|
* Anything asserted per week has to be seeded from here rather than from
|
|
* `daysAgo`: weeks start on Monday, so "three days ago" is last week on a
|
|
* Wednesday and this week on a Saturday, and a suite written the second way
|
|
* fails on the days the calendar disagrees.
|
|
*/
|
|
function startOfThisWeek(): Date {
|
|
const now = new Date();
|
|
const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
|
|
start.setUTCDate(start.getUTCDate() - ((start.getUTCDay() + 6) % 7));
|
|
return start;
|
|
}
|
|
|
|
/** `days` into the week beginning at `weekStart`. Negative walks back a week. */
|
|
function intoWeek(weekStart: Date, days: number): Date {
|
|
const date = new Date(weekStart);
|
|
date.setUTCDate(date.getUTCDate() + days);
|
|
return date;
|
|
}
|
|
|
|
let sequence = 0;
|
|
|
|
async function seedEvent(params: {
|
|
name: AnalyticsEventName;
|
|
occurredAt: Date;
|
|
userId?: string;
|
|
anonymousId?: string;
|
|
channel?: AcquisitionChannel;
|
|
}) {
|
|
sequence += 1;
|
|
await db.analyticsEvent.create({
|
|
data: {
|
|
name: params.name,
|
|
dedupeKey: `${params.name}:seed-${sequence}`,
|
|
occurredAt: params.occurredAt,
|
|
userId: params.userId ?? null,
|
|
anonymousId: params.anonymousId ?? null,
|
|
channel: params.channel ?? null,
|
|
},
|
|
});
|
|
}
|
|
|
|
beforeEach(() => {
|
|
sequence = 0;
|
|
vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'true');
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllEnvs();
|
|
});
|
|
|
|
describe('getScoreboard', () => {
|
|
it('returns an empty week for every week in the window when nothing happened', async () => {
|
|
const scoreboard = await getScoreboard({ weeks: 4 });
|
|
|
|
expect(scoreboard.weeks).toHaveLength(4);
|
|
expect(scoreboard.weeks.every((week) => week.visitors === 0)).toBe(true);
|
|
expect(scoreboard.channels).toEqual([]);
|
|
expect(scoreboard.paidAccounts).toEqual([]);
|
|
});
|
|
|
|
it('counts a returning visitor once per week, not once per visit', async () => {
|
|
// Landing views are deduped per visitor per day, so the same person on three
|
|
// days is three rows. Weekly visitors is a distinct count over the id.
|
|
// Seeded into last week, which is whole however the suite is scheduled.
|
|
const lastWeek = intoWeek(startOfThisWeek(), -7);
|
|
for (const day of [0, 1, 2]) {
|
|
await seedEvent({
|
|
name: 'LANDING_VIEW',
|
|
occurredAt: intoWeek(lastWeek, day),
|
|
anonymousId: 'visitor-one',
|
|
channel: 'GITHUB',
|
|
});
|
|
}
|
|
await seedEvent({
|
|
name: 'LANDING_VIEW',
|
|
occurredAt: intoWeek(lastWeek, 1),
|
|
anonymousId: 'visitor-two',
|
|
channel: 'GOOGLE',
|
|
});
|
|
|
|
const scoreboard = await getScoreboard({ weeks: 2 });
|
|
const total = scoreboard.weeks.reduce((sum, week) => sum + week.visitors, 0);
|
|
|
|
expect(total).toBe(2);
|
|
});
|
|
|
|
it('reads a signed-up visitor through the channel on their account', async () => {
|
|
const user = await createUser();
|
|
await db.userAcquisition.create({
|
|
data: { userId: user.id, channel: 'YOUTUBE', anonymousId: 'visitor-three' },
|
|
});
|
|
|
|
// The visitor event carries GITHUB from the cookie, but the account says
|
|
// YouTube. The account wins, so correcting a channel corrects its history.
|
|
await seedEvent({
|
|
name: 'LANDING_VIEW',
|
|
occurredAt: daysAgo(2),
|
|
anonymousId: 'visitor-three',
|
|
channel: 'GITHUB',
|
|
userId: user.id,
|
|
});
|
|
await seedEvent({
|
|
name: 'SIGNUP_COMPLETED',
|
|
occurredAt: daysAgo(2),
|
|
userId: user.id,
|
|
anonymousId: 'visitor-three',
|
|
});
|
|
|
|
const scoreboard = await getScoreboard({ weeks: 2 });
|
|
const youtube = scoreboard.channels.find((row) => row.channel === 'YOUTUBE');
|
|
|
|
expect(youtube).toMatchObject({ visitors: 1, signups: 1 });
|
|
expect(scoreboard.channels.find((row) => row.channel === 'GITHUB')).toBeUndefined();
|
|
});
|
|
|
|
it('carries subscriptions started before the window into the running total', async () => {
|
|
// The pair has to land in the week the assertions read, which is this one.
|
|
const thisWeek = startOfThisWeek();
|
|
await seedEvent({ name: 'SUBSCRIPTION_STARTED', occurredAt: daysAgo(120) });
|
|
await seedEvent({ name: 'SUBSCRIPTION_STARTED', occurredAt: thisWeek });
|
|
await seedEvent({ name: 'SUBSCRIPTION_CANCELED', occurredAt: thisWeek });
|
|
|
|
const scoreboard = await getScoreboard({ weeks: 2 });
|
|
const last = scoreboard.weeks[scoreboard.weeks.length - 1];
|
|
|
|
// One from before the window, plus one started and one canceled inside it.
|
|
expect(last?.activePaid).toBe(1);
|
|
expect(last?.newPaid).toBe(1);
|
|
expect(last?.canceled).toBe(1);
|
|
});
|
|
|
|
it('flags a paid account that has produced nothing recently', async () => {
|
|
const busy = await createUser({ subscriptionStatus: 'ACTIVE' });
|
|
const silent = await createUser({ subscriptionStatus: 'ACTIVE' });
|
|
const trialing = await createUser({ subscriptionStatus: 'TRIALING' });
|
|
await createUser({ subscriptionStatus: 'FREE' });
|
|
|
|
await seedEvent({ name: 'VIDEO_ADDED', occurredAt: daysAgo(2), userId: busy.id });
|
|
await seedEvent({ name: 'SHARE_LINK_CREATED', occurredAt: daysAgo(20), userId: busy.id });
|
|
await seedEvent({
|
|
name: 'VIDEO_ADDED',
|
|
occurredAt: daysAgo(AT_RISK_SILENT_DAYS + 5),
|
|
userId: silent.id,
|
|
});
|
|
// A signup is not a value event, so it must not clear the risk flag.
|
|
await seedEvent({ name: 'SIGNUP_COMPLETED', occurredAt: daysAgo(1), userId: trialing.id });
|
|
|
|
const scoreboard = await getScoreboard({ weeks: 4 });
|
|
const ids = scoreboard.paidAccounts.map((row) => row.userId).sort();
|
|
const atRisk = scoreboard.atRisk.map((row) => row.userId).sort();
|
|
|
|
expect(ids).toEqual([busy.id, silent.id, trialing.id].sort());
|
|
expect(atRisk).toEqual([silent.id, trialing.id].sort());
|
|
|
|
const busyRow = scoreboard.paidAccounts.find((row) => row.userId === busy.id);
|
|
expect(busyRow?.valueEvents7).toBe(1);
|
|
expect(busyRow?.valueEvents30).toBe(2);
|
|
});
|
|
});
|
|
|
|
// The cohort comparison is another block of raw SQL, and the part most easily
|
|
// got wrong is the observation window: a conversion that arrives two months
|
|
// after signup belongs to neither cohort's score.
|
|
describe('getCohortComparison', () => {
|
|
const CUTOVER = '2026-03-01T00:00:00.000Z';
|
|
const NOW = new Date('2026-05-01T00:00:00.000Z');
|
|
|
|
async function seedAccount(params: { createdAt: string; trialAt?: string; paidAt?: string }) {
|
|
const user = await createUser();
|
|
await db.user.update({
|
|
where: { id: user.id },
|
|
data: { createdAt: new Date(params.createdAt) },
|
|
});
|
|
|
|
if (params.trialAt) {
|
|
await seedEvent({
|
|
name: 'TRIAL_STARTED',
|
|
occurredAt: new Date(params.trialAt),
|
|
userId: user.id,
|
|
});
|
|
}
|
|
if (params.paidAt) {
|
|
await seedEvent({
|
|
name: 'SUBSCRIPTION_STARTED',
|
|
occurredAt: new Date(params.paidAt),
|
|
userId: user.id,
|
|
});
|
|
}
|
|
|
|
return user;
|
|
}
|
|
|
|
it('is null on a deployment that never named a switchover date', async () => {
|
|
vi.stubEnv('OPENFRAME_CARDLESS_TRIAL_LAUNCHED_AT', '');
|
|
|
|
expect(await getCohortComparison(NOW)).toBeNull();
|
|
});
|
|
|
|
it('splits accounts by the cutover and scores each within its 30 days', async () => {
|
|
vi.stubEnv('OPENFRAME_CARDLESS_TRIAL_LAUNCHED_AT', CUTOVER);
|
|
|
|
// Card first: one converted inside the window, one long after it.
|
|
await seedAccount({
|
|
createdAt: '2026-02-10T00:00:00.000Z',
|
|
paidAt: '2026-02-20T00:00:00.000Z',
|
|
});
|
|
await seedAccount({
|
|
createdAt: '2026-02-10T00:00:00.000Z',
|
|
paidAt: '2026-03-25T00:00:00.000Z',
|
|
});
|
|
// Cardless: both took the trial, one paid for it.
|
|
await seedAccount({
|
|
createdAt: '2026-03-10T00:00:00.000Z',
|
|
trialAt: '2026-03-10T00:00:00.000Z',
|
|
paidAt: '2026-03-20T00:00:00.000Z',
|
|
});
|
|
await seedAccount({
|
|
createdAt: '2026-03-15T00:00:00.000Z',
|
|
trialAt: '2026-03-15T00:00:00.000Z',
|
|
});
|
|
// Older than the matched window, and too new to have been observed yet.
|
|
await seedAccount({
|
|
createdAt: '2026-01-01T00:00:00.000Z',
|
|
paidAt: '2026-01-05T00:00:00.000Z',
|
|
});
|
|
await seedAccount({
|
|
createdAt: '2026-04-15T00:00:00.000Z',
|
|
paidAt: '2026-04-16T00:00:00.000Z',
|
|
});
|
|
|
|
const comparison = await getCohortComparison(NOW);
|
|
|
|
expect(comparison?.rows).toEqual([
|
|
expect.objectContaining({ cohort: 'CARD_FIRST', signups: 2, trials: 0, paid: 1 }),
|
|
expect.objectContaining({ cohort: 'CARDLESS', signups: 2, trials: 2, paid: 1 }),
|
|
]);
|
|
});
|
|
|
|
it('reports both cohorts as empty rows rather than omitting them', async () => {
|
|
vi.stubEnv('OPENFRAME_CARDLESS_TRIAL_LAUNCHED_AT', CUTOVER);
|
|
|
|
const comparison = await getCohortComparison(NOW);
|
|
|
|
expect(comparison?.rows.map((row) => row.cohort)).toEqual(['CARD_FIRST', 'CARDLESS']);
|
|
expect(comparison?.rows.every((row) => row.signups === 0)).toBe(true);
|
|
});
|
|
});
|