diff --git a/services/platform-service/src/modules/broadcasts/broadcasts.test.ts b/services/platform-service/src/modules/broadcasts/broadcasts.test.ts new file mode 100644 index 00000000..ce57bf55 --- /dev/null +++ b/services/platform-service/src/modules/broadcasts/broadcasts.test.ts @@ -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'); + }); +}); diff --git a/services/platform-service/src/modules/surveys/surveys.test.ts b/services/platform-service/src/modules/surveys/surveys.test.ts new file mode 100644 index 00000000..92d2f8b8 --- /dev/null +++ b/services/platform-service/src/modules/surveys/surveys.test.ts @@ -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); + }); +});