Files
stats/packages/db/src/buffers/profile-buffer.test.ts
Carl-Gerhard Lindesvärd 90881e5ffb wip
2026-03-18 21:16:16 +01:00

152 lines
4.3 KiB
TypeScript

import { getRedisCache } from '@openpanel/redis';
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { getSafeJson } from '@openpanel/json';
import type { IClickhouseProfile } from '../services/profile.service';
// Mock chQuery to avoid hitting real ClickHouse
vi.mock('../clickhouse/client', () => ({
ch: {
insert: vi.fn().mockResolvedValue(undefined),
},
chQuery: vi.fn().mockResolvedValue([]),
TABLE_NAMES: {
profiles: 'profiles',
},
}));
import { ProfileBuffer } from './profile-buffer';
import { chQuery } from '../clickhouse/client';
const redis = getRedisCache();
function makeProfile(overrides: Partial<IClickhouseProfile>): IClickhouseProfile {
return {
id: 'profile-1',
project_id: 'project-1',
first_name: '',
last_name: '',
email: '',
avatar: '',
properties: {},
is_external: true,
created_at: new Date().toISOString(),
groups: [],
...overrides,
};
}
beforeEach(async () => {
await redis.flushdb();
vi.mocked(chQuery).mockResolvedValue([]);
});
afterAll(async () => {
try {
await redis.quit();
} catch {}
});
describe('ProfileBuffer', () => {
let profileBuffer: ProfileBuffer;
beforeEach(() => {
profileBuffer = new ProfileBuffer();
});
it('adds a profile to the buffer', async () => {
const profile = makeProfile({ first_name: 'John', email: 'john@example.com' });
const sizeBefore = await profileBuffer.getBufferSize();
await profileBuffer.add(profile);
const sizeAfter = await profileBuffer.getBufferSize();
expect(sizeAfter).toBe(sizeBefore + 1);
});
it('merges subsequent updates via cache (sequential calls)', async () => {
const identifyProfile = makeProfile({
first_name: 'John',
email: 'john@example.com',
groups: [],
});
const groupProfile = makeProfile({
first_name: '',
email: '',
groups: ['group-abc'],
});
// Sequential: identify first, then group
await profileBuffer.add(identifyProfile);
await profileBuffer.add(groupProfile);
// Second add should read the cached identify profile and merge groups in
const cached = await profileBuffer.fetchFromCache('profile-1', 'project-1');
expect(cached?.first_name).toBe('John');
expect(cached?.email).toBe('john@example.com');
expect(cached?.groups).toContain('group-abc');
});
it('race condition: concurrent identify + group calls preserve all data', async () => {
const identifyProfile = makeProfile({
first_name: 'John',
email: 'john@example.com',
groups: [],
});
const groupProfile = makeProfile({
first_name: '',
email: '',
groups: ['group-abc'],
});
// Both calls run concurrently — the per-profile lock serializes them so the
// second one reads the first's result from cache and merges correctly.
await Promise.all([
profileBuffer.add(identifyProfile),
profileBuffer.add(groupProfile),
]);
const cached = await profileBuffer.fetchFromCache('profile-1', 'project-1');
expect(cached?.first_name).toBe('John');
expect(cached?.email).toBe('john@example.com');
expect(cached?.groups).toContain('group-abc');
});
it('race condition: concurrent writes produce one merged buffer entry', async () => {
const identifyProfile = makeProfile({
first_name: 'John',
email: 'john@example.com',
groups: [],
});
const groupProfile = makeProfile({
first_name: '',
email: '',
groups: ['group-abc'],
});
const sizeBefore = await profileBuffer.getBufferSize();
await Promise.all([
profileBuffer.add(identifyProfile),
profileBuffer.add(groupProfile),
]);
const sizeAfter = await profileBuffer.getBufferSize();
// The second add merges into the first — only 2 buffer entries total
// (one from identify, one merged update with group)
expect(sizeAfter).toBe(sizeBefore + 2);
// The last entry in the buffer should have both name and group
const rawEntries = await redis.lrange('profile-buffer', 0, -1);
const entries = rawEntries.map((e) => getSafeJson<IClickhouseProfile>(e));
const lastEntry = entries[entries.length - 1];
expect(lastEntry?.first_name).toBe('John');
expect(lastEntry?.groups).toContain('group-abc');
});
});