test(platform-service): add broadcast + survey module tests — 43 tests
Broadcasts (23 tests): schema validation, targeting engine (segments, platform, version range, country, specific users, percentage rollout), query builder, context extraction. Surveys (20 tests): schema validation (create, update, question, answer, triggers), completion rate, answer type validation (NPS, text, rating, dropdown, scale, ranking), length/range constraints.
This commit is contained in:
parent
c2c5dd518a
commit
4e7401d164
@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Broadcast module tests — schema validation and targeting engine.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { CreateBroadcastSchema, UpdateBroadcastSchema, InAppMessageActionSchema } from './types.js';
|
||||
import { evaluateTarget, buildTargetQuery, extractTargetingContext } from './targeting.js';
|
||||
import type { BroadcastTarget, TargetingContext } from './types.js';
|
||||
|
||||
// ── Schema validation ──
|
||||
|
||||
describe('CreateBroadcastSchema', () => {
|
||||
it('validates minimal broadcast', () => {
|
||||
const result = CreateBroadcastSchema.safeParse({
|
||||
title: 'Welcome!',
|
||||
body: 'Welcome to the app',
|
||||
target: {},
|
||||
channels: ['in_app'],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects empty channels', () => {
|
||||
const result = CreateBroadcastSchema.safeParse({
|
||||
title: 'Welcome!',
|
||||
body: 'Hello',
|
||||
target: {},
|
||||
channels: [],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('validates full broadcast with targeting + media', () => {
|
||||
const result = CreateBroadcastSchema.safeParse({
|
||||
title: 'Big Update!',
|
||||
body: 'Check out the new features',
|
||||
bodyMarkdown: '# Big Update\n\nCheck out the new features',
|
||||
ctaText: 'Learn More',
|
||||
ctaUrl: 'https://example.com/update',
|
||||
imageUrl: 'https://example.com/img.png',
|
||||
media: [{ type: 'image', url: 'https://example.com/hero.jpg', width: 800, height: 600 }],
|
||||
target: {
|
||||
userSegments: ['pro'],
|
||||
platforms: ['ios', 'android'],
|
||||
percentageRollout: 50,
|
||||
},
|
||||
channels: ['push', 'in_app'],
|
||||
scheduledAt: '2026-04-01T10:00:00.000Z',
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('UpdateBroadcastSchema', () => {
|
||||
it('allows partial updates', () => {
|
||||
const result = UpdateBroadcastSchema.safeParse({ title: 'New Title' });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('allows status change', () => {
|
||||
const result = UpdateBroadcastSchema.safeParse({ status: 'paused' });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('InAppMessageActionSchema', () => {
|
||||
it('validates read action', () => {
|
||||
expect(InAppMessageActionSchema.safeParse({ action: 'read' }).success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects invalid action', () => {
|
||||
expect(InAppMessageActionSchema.safeParse({ action: 'delete' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Targeting engine ──
|
||||
|
||||
describe('evaluateTarget', () => {
|
||||
const baseContext: TargetingContext = {
|
||||
userId: 'user-1',
|
||||
productId: 'testprod',
|
||||
platform: 'ios',
|
||||
appVersion: '2.1.0',
|
||||
osVersion: '17.0',
|
||||
countryCode: 'US',
|
||||
regionCode: 'US-CA',
|
||||
userSegments: ['pro'],
|
||||
};
|
||||
|
||||
it('matches with empty target (all users)', () => {
|
||||
expect(evaluateTarget({}, baseContext)).toBe(true);
|
||||
});
|
||||
|
||||
it('matches user segment', () => {
|
||||
const target: BroadcastTarget = { userSegments: ['pro', 'enterprise'] };
|
||||
expect(evaluateTarget(target, baseContext)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects non-matching segment', () => {
|
||||
const target: BroadcastTarget = { userSegments: ['churned'] };
|
||||
expect(evaluateTarget(target, baseContext)).toBe(false);
|
||||
});
|
||||
|
||||
it('matches platform', () => {
|
||||
const target: BroadcastTarget = { platforms: ['ios', 'android'] };
|
||||
expect(evaluateTarget(target, baseContext)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects non-matching platform', () => {
|
||||
const target: BroadcastTarget = { platforms: ['android'] };
|
||||
expect(evaluateTarget(target, baseContext)).toBe(false);
|
||||
});
|
||||
|
||||
it('matches app version range', () => {
|
||||
const target: BroadcastTarget = { appVersionMin: '2.0.0', appVersionMax: '3.0.0' };
|
||||
expect(evaluateTarget(target, baseContext)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects below min version', () => {
|
||||
const target: BroadcastTarget = { appVersionMin: '3.0.0' };
|
||||
expect(evaluateTarget(target, baseContext)).toBe(false);
|
||||
});
|
||||
|
||||
it('matches country code', () => {
|
||||
const target: BroadcastTarget = { countryCodes: ['US', 'CA'] };
|
||||
expect(evaluateTarget(target, baseContext)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects non-matching country', () => {
|
||||
const target: BroadcastTarget = { countryCodes: ['DE'] };
|
||||
expect(evaluateTarget(target, baseContext)).toBe(false);
|
||||
});
|
||||
|
||||
it('matches specific user ID', () => {
|
||||
const target: BroadcastTarget = { specificUserIds: ['user-1', 'user-2'] };
|
||||
expect(evaluateTarget(target, baseContext)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects non-matching user ID', () => {
|
||||
const target: BroadcastTarget = { specificUserIds: ['user-99'] };
|
||||
expect(evaluateTarget(target, baseContext)).toBe(false);
|
||||
});
|
||||
|
||||
it('percentage rollout is deterministic', () => {
|
||||
const target: BroadcastTarget = { percentageRollout: 50 };
|
||||
const result1 = evaluateTarget(target, baseContext);
|
||||
const result2 = evaluateTarget(target, baseContext);
|
||||
expect(result1).toBe(result2); // Same user → same result
|
||||
});
|
||||
});
|
||||
|
||||
// ── Target query builder ──
|
||||
|
||||
describe('buildTargetQuery', () => {
|
||||
it('returns empty for empty target', () => {
|
||||
const { whereClauses, parameters } = buildTargetQuery({});
|
||||
expect(whereClauses).toHaveLength(0);
|
||||
expect(parameters).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('builds platform filter', () => {
|
||||
const { whereClauses, parameters } = buildTargetQuery({ platforms: ['ios', 'android'] });
|
||||
expect(whereClauses).toContain('(c.platform IN @platforms)');
|
||||
expect(parameters.find(p => p.name === '@platforms')?.value).toEqual(['ios', 'android']);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Context extraction ──
|
||||
|
||||
describe('extractTargetingContext', () => {
|
||||
it('extracts from headers and JWT', () => {
|
||||
const ctx = extractTargetingContext(
|
||||
{
|
||||
'x-product-id': 'myapp',
|
||||
'x-platform': 'ios',
|
||||
'x-app-version': '2.0.0',
|
||||
'x-os-version': '17.0',
|
||||
'x-country-code': 'US',
|
||||
},
|
||||
{ sub: 'user-1', segments: ['pro'] }
|
||||
);
|
||||
expect(ctx.userId).toBe('user-1');
|
||||
expect(ctx.platform).toBe('ios');
|
||||
expect(ctx.countryCode).toBe('US');
|
||||
expect(ctx.userSegments).toEqual(['pro']);
|
||||
});
|
||||
|
||||
it('falls back to defaults', () => {
|
||||
const ctx = extractTargetingContext({}, undefined);
|
||||
expect(ctx.userId).toBe('anonymous');
|
||||
expect(ctx.platform).toBe('web');
|
||||
expect(ctx.appVersion).toBe('0.0.0');
|
||||
});
|
||||
});
|
||||
219
services/platform-service/src/modules/surveys/surveys.test.ts
Normal file
219
services/platform-service/src/modules/surveys/surveys.test.ts
Normal file
@ -0,0 +1,219 @@
|
||||
/**
|
||||
* Survey module tests — schema validation and answer validation.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
CreateSurveySchema,
|
||||
UpdateSurveySchema,
|
||||
SubmitAnswerSchema,
|
||||
QuestionSchema,
|
||||
computeCompletionRate,
|
||||
validateAnswerType,
|
||||
type Question,
|
||||
type QuestionAnswer,
|
||||
type SurveyMetrics,
|
||||
} from './types.js';
|
||||
|
||||
// ── Schema validation ──
|
||||
|
||||
describe('CreateSurveySchema', () => {
|
||||
it('validates minimal survey', () => {
|
||||
const result = CreateSurveySchema.safeParse({
|
||||
title: 'User Feedback',
|
||||
questions: [
|
||||
{ id: 'q1', type: 'nps', text: 'How likely are you to recommend?', required: true },
|
||||
],
|
||||
displayTrigger: { type: 'immediate' },
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects empty questions', () => {
|
||||
const result = CreateSurveySchema.safeParse({
|
||||
title: 'Bad Survey',
|
||||
questions: [],
|
||||
displayTrigger: { type: 'immediate' },
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('validates survey with all trigger types', () => {
|
||||
for (const trigger of [
|
||||
{ type: 'immediate' as const },
|
||||
{ type: 'delay_seconds' as const, seconds: 30 },
|
||||
{ type: 'event' as const, eventName: 'purchase_complete' },
|
||||
{ type: 'page_view' as const, pagePattern: '/settings*' },
|
||||
]) {
|
||||
const result = CreateSurveySchema.safeParse({
|
||||
title: 'Test',
|
||||
questions: [{ id: 'q1', type: 'nps', text: 'Rate us', required: true }],
|
||||
displayTrigger: trigger,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('validates survey with incentive', () => {
|
||||
const result = CreateSurveySchema.safeParse({
|
||||
title: 'Feedback',
|
||||
questions: [{ id: 'q1', type: 'text_short', text: 'Any thoughts?', required: false }],
|
||||
displayTrigger: { type: 'immediate' },
|
||||
incentive: { type: 'pro_days', amount: 7 },
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('UpdateSurveySchema', () => {
|
||||
it('allows partial updates', () => {
|
||||
expect(UpdateSurveySchema.safeParse({ title: 'New Title' }).success).toBe(true);
|
||||
});
|
||||
|
||||
it('allows status change', () => {
|
||||
expect(UpdateSurveySchema.safeParse({ status: 'closed' }).success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('QuestionSchema', () => {
|
||||
it('validates single choice question with options', () => {
|
||||
const result = QuestionSchema.safeParse({
|
||||
id: 'q1',
|
||||
type: 'single_choice',
|
||||
text: 'Pick one',
|
||||
required: true,
|
||||
options: [
|
||||
{ id: 'o1', text: 'Option A' },
|
||||
{ id: 'o2', text: 'Option B' },
|
||||
],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('validates question with conditional logic', () => {
|
||||
const result = QuestionSchema.safeParse({
|
||||
id: 'q2',
|
||||
type: 'text_short',
|
||||
text: 'Why?',
|
||||
required: false,
|
||||
showIf: { questionId: 'q1', operator: 'equals', value: 'o1' },
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SubmitAnswerSchema', () => {
|
||||
it('validates NPS answer', () => {
|
||||
const result = SubmitAnswerSchema.safeParse({
|
||||
questionId: 'q1',
|
||||
answer: { type: 'nps', value: 9 },
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('validates text answer', () => {
|
||||
const result = SubmitAnswerSchema.safeParse({
|
||||
questionId: 'q2',
|
||||
answer: { type: 'text', value: 'Great app!' },
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('validates ranking answer', () => {
|
||||
const result = SubmitAnswerSchema.safeParse({
|
||||
questionId: 'q3',
|
||||
answer: { type: 'ranking', rankedOptionIds: ['o2', 'o1', 'o3'] },
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects NPS value out of range', () => {
|
||||
const result = SubmitAnswerSchema.safeParse({
|
||||
questionId: 'q1',
|
||||
answer: { type: 'nps', value: 11 },
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Helper functions ──
|
||||
|
||||
describe('computeCompletionRate', () => {
|
||||
it('computes rate correctly', () => {
|
||||
const metrics: SurveyMetrics = {
|
||||
impressions: 100,
|
||||
starts: 50,
|
||||
completions: 25,
|
||||
avgTimeSeconds: 60,
|
||||
incentiveClaims: 10,
|
||||
};
|
||||
expect(computeCompletionRate(metrics)).toBe(0.5);
|
||||
});
|
||||
|
||||
it('returns 0 when no starts', () => {
|
||||
const metrics: SurveyMetrics = {
|
||||
impressions: 0,
|
||||
starts: 0,
|
||||
completions: 0,
|
||||
avgTimeSeconds: 0,
|
||||
incentiveClaims: 0,
|
||||
};
|
||||
expect(computeCompletionRate(metrics)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateAnswerType', () => {
|
||||
it('validates matching answer type', () => {
|
||||
const question: Question = { id: 'q1', type: 'nps', text: 'Rate us', required: true };
|
||||
const answer: QuestionAnswer = { type: 'nps', value: 8 };
|
||||
expect(validateAnswerType(question, answer)).toEqual({ valid: true });
|
||||
});
|
||||
|
||||
it('rejects mismatched answer type', () => {
|
||||
const question: Question = { id: 'q1', type: 'nps', text: 'Rate us', required: true };
|
||||
const answer: QuestionAnswer = { type: 'text', value: 'hello' };
|
||||
const result = validateAnswerType(question, answer);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('Expected nps');
|
||||
});
|
||||
|
||||
it('validates text length constraints', () => {
|
||||
const question: Question = {
|
||||
id: 'q1',
|
||||
type: 'text_short',
|
||||
text: 'Name?',
|
||||
required: true,
|
||||
minLength: 2,
|
||||
maxLength: 50,
|
||||
};
|
||||
expect(validateAnswerType(question, { type: 'text', value: 'A' }).valid).toBe(false);
|
||||
expect(validateAnswerType(question, { type: 'text', value: 'Alice' }).valid).toBe(true);
|
||||
});
|
||||
|
||||
it('validates rating range constraints', () => {
|
||||
const question: Question = {
|
||||
id: 'q1',
|
||||
type: 'rating',
|
||||
text: 'Rate',
|
||||
required: true,
|
||||
minValue: 1,
|
||||
maxValue: 5,
|
||||
};
|
||||
expect(validateAnswerType(question, { type: 'rating', value: 0 }).valid).toBe(false);
|
||||
expect(validateAnswerType(question, { type: 'rating', value: 3 }).valid).toBe(true);
|
||||
expect(validateAnswerType(question, { type: 'rating', value: 6 }).valid).toBe(false);
|
||||
});
|
||||
|
||||
it('treats dropdown as single_choice', () => {
|
||||
const question: Question = { id: 'q1', type: 'dropdown', text: 'Pick', required: true };
|
||||
expect(validateAnswerType(question, { type: 'single_choice', optionId: 'o1' }).valid).toBe(
|
||||
true
|
||||
);
|
||||
expect(validateAnswerType(question, { type: 'text', value: 'bad' }).valid).toBe(false);
|
||||
});
|
||||
|
||||
it('treats scale as rating', () => {
|
||||
const question: Question = { id: 'q1', type: 'scale', text: 'How much?', required: true };
|
||||
expect(validateAnswerType(question, { type: 'rating', value: 5 }).valid).toBe(true);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user