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:
saravanakumardb1 2026-03-19 22:16:28 -07:00
parent c2c5dd518a
commit 4e7401d164
2 changed files with 413 additions and 0 deletions

View File

@ -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');
});
});

View 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);
});
});