Files
plunk/apps/api/src/services/__tests__/SegmentService.test.ts
T

713 lines
23 KiB
TypeScript

import {beforeEach, describe, expect, it} from 'vitest';
import {type SegmentFilter, SegmentService} from '../SegmentService';
import {factories, getPrismaClient} from '../../../../../test/helpers';
// Type for intentionally invalid filter input (used for testing validation)
type InvalidFilterInput = Partial<SegmentFilter>;
describe('SegmentService', () => {
let projectId: string;
const prisma = getPrismaClient();
beforeEach(async () => {
const {project} = await factories.createUserWithProject();
projectId = project.id;
});
describe('Segment Filtering', () => {
it('should filter contacts by subscribed status', async () => {
const subscribed = await factories.createContact({
projectId,
subscribed: true,
});
const _unsubscribed = await factories.createContact({
projectId,
subscribed: false,
});
const segment = await factories.createSegment(projectId, {
name: 'Subscribed Users',
filters: [{field: 'subscribed', operator: 'equals', value: true}],
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.data).toHaveLength(1);
expect(result.data[0].id).toBe(subscribed.id);
});
it('should filter contacts by custom data fields', async () => {
const proUser = await factories.createContact({
projectId,
data: {plan: 'pro', tier: 'premium'},
});
const _freeUser = await factories.createContact({
projectId,
data: {plan: 'free', tier: 'basic'},
});
const segment = await factories.createSegment(projectId, {
name: 'Pro Users',
filters: [{field: 'data.plan', operator: 'equals', value: 'pro'}],
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.data).toHaveLength(1);
expect(result.data[0].id).toBe(proUser.id);
});
it('should filter contacts with multiple conditions', async () => {
const target = await factories.createContact({
projectId,
subscribed: true,
data: {plan: 'pro', active: true},
});
const _notSubscribed = await factories.createContact({
projectId,
subscribed: false,
data: {plan: 'pro', active: true},
});
const _notPro = await factories.createContact({
projectId,
subscribed: true,
data: {plan: 'free', active: true},
});
const segment = await factories.createSegment(projectId, {
name: 'Active Pro Subscribers',
filters: [
{field: 'subscribed', operator: 'equals', value: true},
{field: 'data.plan', operator: 'equals', value: 'pro'},
{field: 'data.active', operator: 'equals', value: true},
],
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.data).toHaveLength(1);
expect(result.data[0].id).toBe(target.id);
});
it('should support notEquals operator', async () => {
const pro = await factories.createContact({
projectId,
data: {plan: 'pro'},
});
const _free = await factories.createContact({
projectId,
data: {plan: 'free'},
});
const segment = await factories.createSegment(projectId, {
name: 'Non-Free Users',
filters: [{field: 'data.plan', operator: 'notEquals', value: 'free'}],
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.data).toHaveLength(1);
expect(result.data[0].id).toBe(pro.id);
});
it('should support contains operator for strings', async () => {
const match = await factories.createContact({
projectId,
email: '[email protected]',
});
const _noMatch = await factories.createContact({
projectId,
email: '[email protected]',
});
const segment = await factories.createSegment(projectId, {
name: 'Company Emails',
filters: [{field: 'email', operator: 'contains', value: 'company'}],
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.data).toHaveLength(1);
expect(result.data[0].id).toBe(match.id);
});
it('should support exists operator for custom fields', async () => {
const withField = await factories.createContact({
projectId,
data: {company: 'Acme Inc'},
});
await factories.createContact({
projectId,
data: {name: 'John'},
});
const segment = await factories.createSegment(projectId, {
name: 'Has Company',
filters: [{field: 'data.company', operator: 'exists', value: true}],
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.data).toHaveLength(1);
expect(result.data[0].id).toBe(withField.id);
});
it('should handle empty segments', async () => {
await factories.createContact({
projectId,
data: {plan: 'free'},
});
const segment = await factories.createSegment(projectId, {
name: 'Enterprise Users',
filters: [{field: 'data.plan', operator: 'equals', value: 'enterprise'}],
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.data).toHaveLength(0);
expect(result.total).toBe(0);
});
});
describe('Segment Membership', () => {
it('should return correct member count', async () => {
await factories.createContact({
projectId,
subscribed: true,
});
await factories.createContact({
projectId,
subscribed: true,
});
await factories.createContact({
projectId,
subscribed: false,
});
const segment = await factories.createSegment(projectId, {
filters: [{field: 'subscribed', operator: 'equals', value: true}],
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.total).toBe(2);
expect(result.data).toHaveLength(2);
});
it('should support pagination', async () => {
// Create 25 contacts
for (let i = 0; i < 25; i++) {
await factories.createContact({
projectId,
subscribed: true,
});
}
const segment = await factories.createSegment(projectId, {
filters: [{field: 'subscribed', operator: 'equals', value: true}],
});
const page1 = await SegmentService.getContacts(projectId, segment.id, 1, 10);
expect(page1.data).toHaveLength(10);
expect(page1.total).toBe(25);
expect(page1.totalPages).toBe(3);
const page2 = await SegmentService.getContacts(projectId, segment.id, 2, 10);
expect(page2.data).toHaveLength(10);
const page3 = await SegmentService.getContacts(projectId, segment.id, 3, 10);
expect(page3.data).toHaveLength(5);
});
});
describe('Segment Management', () => {
it('should create segment with filters', async () => {
const segment = await factories.createSegment(projectId, {
name: 'VIP Customers',
filters: [
{field: 'data.vip', operator: 'equals', value: true},
{field: 'subscribed', operator: 'equals', value: true},
],
});
expect(segment.name).toBe('VIP Customers');
expect(segment.projectId).toBe(projectId);
// Segments now use condition object with groups containing filters
expect(segment.condition).toBeDefined();
expect(typeof segment.condition).toBe('object');
});
it('should list all segments for a project', async () => {
await factories.createSegment(projectId, {name: 'Segment 1'});
await factories.createSegment(projectId, {name: 'Segment 2'});
await factories.createSegment(projectId, {name: 'Segment 3'});
const segments = await SegmentService.list(projectId);
expect(segments).toHaveLength(3);
});
it('should get specific segment by id', async () => {
const segment = await factories.createSegment(projectId, {
name: 'Test Segment',
});
const retrieved = await SegmentService.get(projectId, segment.id);
expect(retrieved.id).toBe(segment.id);
expect(retrieved.name).toBe('Test Segment');
});
it('should throw error when segment not found', async () => {
await expect(SegmentService.get(projectId, 'non-existent-id')).rejects.toThrow('Segment not found');
});
});
describe('Dynamic Segment Updates', () => {
it('should reflect in segment when contact data changes', async () => {
const contact = await factories.createContact({
projectId,
data: {plan: 'free'},
});
const proSegment = await factories.createSegment(projectId, {
name: 'Pro Users',
filters: [{field: 'data.plan', operator: 'equals', value: 'pro'}],
});
// Initially not in segment
let result = await SegmentService.getContacts(projectId, proSegment.id);
expect(result.data).toHaveLength(0);
// Update contact to pro plan
await prisma.contact.update({
where: {id: contact.id},
data: {data: {plan: 'pro'}},
});
// Should now be in segment
result = await SegmentService.getContacts(projectId, proSegment.id);
expect(result.data).toHaveLength(1);
expect(result.data[0].id).toBe(contact.id);
});
it('should be removed from segment when criteria no longer met', async () => {
const contact = await factories.createContact({
projectId,
subscribed: true,
});
const segment = await factories.createSegment(projectId, {
filters: [{field: 'subscribed', operator: 'equals', value: true}],
});
// Initially in segment
let result = await SegmentService.getContacts(projectId, segment.id);
expect(result.data).toHaveLength(1);
// Unsubscribe contact
await prisma.contact.update({
where: {id: contact.id},
data: {subscribed: false},
});
// Should no longer be in segment
result = await SegmentService.getContacts(projectId, segment.id);
expect(result.data).toHaveLength(0);
});
});
describe('Delete Protection for Active Campaigns', () => {
it('should BLOCK deleting segment used in DRAFT campaigns', async () => {
const segment = await factories.createSegment(projectId, {
name: 'VIP Customers',
filters: [{field: 'subscribed', operator: 'equals', value: true}],
});
await factories.createCampaign({
projectId,
segmentId: segment.id,
status: 'DRAFT',
});
await expect(SegmentService.delete(projectId, segment.id)).rejects.toThrow(
/cannot delete segment.*active campaign/i,
);
});
it('should BLOCK deleting segment used in SCHEDULED campaigns', async () => {
const segment = await factories.createSegment(projectId);
await factories.createScheduledCampaign({
projectId,
segmentId: segment.id,
});
await expect(SegmentService.delete(projectId, segment.id)).rejects.toThrow(
/cannot delete segment.*active campaign/i,
);
});
it('should ALLOW deleting segment if all campaigns are SENT', async () => {
const segment = await factories.createSegment(projectId);
await factories.createCampaign({
projectId,
segmentId: segment.id,
status: 'SENT',
});
await SegmentService.delete(projectId, segment.id);
const deleted = await prisma.segment.findUnique({
where: {id: segment.id},
});
expect(deleted).toBeNull();
});
it('should show count of blocking campaigns in error message', async () => {
const segment = await factories.createSegment(projectId);
await factories.createCampaign({
projectId,
segmentId: segment.id,
status: 'DRAFT',
});
await factories.createCampaign({
projectId,
segmentId: segment.id,
status: 'SCHEDULED',
});
await expect(SegmentService.delete(projectId, segment.id)).rejects.toThrow(/2.*active campaign/i);
});
});
describe('Filter Validation', () => {
it('should REJECT empty filters array', async () => {
await expect(
SegmentService.create(projectId, {
name: 'Invalid Segment',
condition: {
logic: 'AND',
groups: [{filters: []}],
},
}),
).rejects.toThrow(/at least one filter/i);
});
it('should REJECT filter without field', async () => {
await expect(
SegmentService.create(projectId, {
name: 'Invalid Segment',
condition: {
logic: 'AND',
groups: [
{
filters: [
{
// Intentionally missing field, cast to bypass compile-time validation
operator: 'equals',
value: 'test',
} as InvalidFilterInput as SegmentFilter,
],
},
],
},
}),
).rejects.toThrow(/field is required/i);
});
it('should REJECT invalid operators', async () => {
await expect(
SegmentService.create(projectId, {
name: 'Invalid Segment',
condition: {
logic: 'AND',
groups: [
{
filters: [
{
field: 'email',
// Intentionally invalid operator
operator: 'DROP TABLE contacts;',
value: 'test',
} as InvalidFilterInput as SegmentFilter,
],
},
],
},
}),
).rejects.toThrow(/invalid operator/i);
});
it('should REJECT operators that need values without values', async () => {
await expect(
SegmentService.create(projectId, {
name: 'Invalid Segment',
condition: {
logic: 'AND',
groups: [
{
filters: [
{
field: 'email',
operator: 'equals',
// Value intentionally omitted
} as InvalidFilterInput as SegmentFilter,
],
},
],
},
}),
).rejects.toThrow(/requires a value/i);
});
it('should ACCEPT valid filters', async () => {
const segment = await SegmentService.create(projectId, {
name: 'Valid Segment',
condition: {
logic: 'AND',
groups: [
{
filters: [
{
field: 'subscribed',
operator: 'equals',
value: true,
},
],
},
],
},
});
expect(segment.id).toBeDefined();
expect(segment.name).toBe('Valid Segment');
});
});
describe('Operator behavior and edge cases', () => {
it('should support notContains operator for email strings', async () => {
const match = await factories.createContact({
projectId,
email: '[email protected]',
});
const other = await factories.createContact({
projectId,
email: '[email protected]',
});
const segment = await factories.createSegment(projectId, {
name: 'Non-company Emails',
filters: [{field: 'email', operator: 'notContains', value: 'company'}],
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.data.map(c => c.id).sort()).toEqual([other.id].sort());
expect(result.data.map(c => c.id)).not.toContain(match.id);
});
it('should support case-insensitive equals/contains for email strings', async () => {
const lower = await factories.createContact({
projectId,
email: '[email protected]',
});
const upper = await factories.createContact({
projectId,
email: '[email protected]',
});
const equalsSegment = await factories.createSegment(projectId, {
name: 'Case-insensitive equals',
filters: [{field: 'email', operator: 'equals', value: '[email protected]'}],
});
const equalsResult = await SegmentService.getContacts(projectId, equalsSegment.id);
const equalsIds = equalsResult.data.map(c => c.id);
expect(equalsIds).toContain(lower.id);
expect(equalsIds).toContain(upper.id);
const containsSegment = await factories.createSegment(projectId, {
name: 'Case-insensitive contains',
filters: [{field: 'email', operator: 'contains', value: 'COMPANY.COM'}],
});
const containsResult = await SegmentService.getContacts(projectId, containsSegment.id);
const containsIds = containsResult.data.map(c => c.id);
expect(containsIds).toContain(lower.id);
expect(containsIds).toContain(upper.id);
});
it('should support notEquals for boolean subscribed field', async () => {
const subscribed = await factories.createContact({
projectId,
subscribed: true,
});
const unsubscribed = await factories.createContact({
projectId,
subscribed: false,
});
const segment = await factories.createSegment(projectId, {
name: 'Not subscribed users',
filters: [{field: 'subscribed', operator: 'notEquals', value: true}],
});
const result = await SegmentService.getContacts(projectId, segment.id);
const ids = result.data.map(c => c.id);
expect(ids).toContain(unsubscribed.id);
expect(ids).not.toContain(subscribed.id);
});
it('should support notContains and notEquals for JSON data fields', async () => {
const acme = await factories.createContact({
projectId,
data: {company: 'Acme Inc'},
});
const other = await factories.createContact({
projectId,
data: {company: 'Other Corp'},
});
const notContainsSegment = await factories.createSegment(projectId, {
name: 'Company not containing "Acme"',
filters: [{field: 'data.company', operator: 'notContains', value: 'Acme'}],
});
const notContainsResult = await SegmentService.getContacts(projectId, notContainsSegment.id);
const notContainsIds = notContainsResult.data.map(c => c.id);
expect(notContainsIds).toContain(other.id);
expect(notContainsIds).not.toContain(acme.id);
const notEqualsSegment = await factories.createSegment(projectId, {
name: 'Company not equal to "Acme Inc"',
filters: [{field: 'data.company', operator: 'notEquals', value: 'Acme Inc'}],
});
const notEqualsResult = await SegmentService.getContacts(projectId, notEqualsSegment.id);
const notEqualsIds = notEqualsResult.data.map(c => c.id);
expect(notEqualsIds).toContain(other.id);
expect(notEqualsIds).not.toContain(acme.id);
});
it('should support exists and notExists for JSON data fields', async () => {
const withCompany = await factories.createContact({
projectId,
data: {company: 'Acme Inc'},
});
const withNullCompany = await factories.createContact({
projectId,
data: {company: null},
});
const existsSegment = await factories.createSegment(projectId, {
name: 'Has company (non-null)',
filters: [{field: 'data.company', operator: 'exists', value: true}],
});
const existsResult = await SegmentService.getContacts(projectId, existsSegment.id);
const existsIds = new Set(existsResult.data.map(c => c.id));
expect(existsIds.has(withCompany.id)).toBe(true);
expect(existsIds.has(withNullCompany.id)).toBe(false);
const notExistsSegment = await factories.createSegment(projectId, {
name: 'No company (null)',
filters: [{field: 'data.company', operator: 'notExists', value: true}],
});
const notExistsResult = await SegmentService.getContacts(projectId, notExistsSegment.id);
const notExistsIds = new Set(notExistsResult.data.map(c => c.id));
expect(notExistsIds.has(withCompany.id)).toBe(false);
expect(notExistsIds.has(withNullCompany.id)).toBe(true);
});
it('should support numeric comparison operators on JSON data fields', async () => {
const low = await factories.createContact({
projectId,
data: {score: 10},
});
const mid = await factories.createContact({
projectId,
data: {score: 50},
});
const high = await factories.createContact({
projectId,
data: {score: 100},
});
const greaterThanSegment = await factories.createSegment(projectId, {
name: 'Score > 10',
filters: [{field: 'data.score', operator: 'greaterThan', value: 10}],
});
const greaterThanResult = await SegmentService.getContacts(projectId, greaterThanSegment.id);
const gtIds = greaterThanResult.data.map(c => c.id);
expect(gtIds).toContain(mid.id);
expect(gtIds).toContain(high.id);
expect(gtIds).not.toContain(low.id);
const lessThanOrEqualSegment = await factories.createSegment(projectId, {
name: 'Score <= 50',
filters: [{field: 'data.score', operator: 'lessThanOrEqual', value: 50}],
});
const lteResult = await SegmentService.getContacts(projectId, lessThanOrEqualSegment.id);
const lteIds = lteResult.data.map(c => c.id);
expect(lteIds).toContain(low.id);
expect(lteIds).toContain(mid.id);
expect(lteIds).not.toContain(high.id);
});
it('should support date comparison operators on createdAt field', async () => {
const older = await factories.createContact({projectId});
// Ensure a small delay so createdAt differs
await new Promise(resolve => setTimeout(resolve, 10));
const newer = await factories.createContact({projectId});
const gtSegment = await factories.createSegment(projectId, {
name: 'Created after first',
filters: [{field: 'createdAt', operator: 'greaterThan', value: older.createdAt.toISOString()}],
});
const gtResult = await SegmentService.getContacts(projectId, gtSegment.id);
const gtIds = gtResult.data.map(c => c.id);
expect(gtIds).toContain(newer.id);
expect(gtIds).not.toContain(older.id);
const lteSegment = await factories.createSegment(projectId, {
name: 'Created on or before second',
filters: [{field: 'createdAt', operator: 'lessThanOrEqual', value: newer.createdAt.toISOString()}],
});
const lteResult = await SegmentService.getContacts(projectId, lteSegment.id);
const lteIds = lteResult.data.map(c => c.id);
expect(lteIds).toContain(older.id);
expect(lteIds).toContain(newer.id);
});
it('should support within operator for recent contacts', async () => {
const recent = await factories.createContact({projectId});
const segment = await factories.createSegment(projectId, {
name: 'Created within last day',
filters: [
{
field: 'createdAt',
operator: 'within',
value: 1,
unit: 'days',
},
],
});
const result = await SegmentService.getContacts(projectId, segment.id);
const ids = result.data.map(c => c.id);
expect(ids).toContain(recent.id);
});
});
});