diff --git a/services/platform-service/src/modules/flags/flags.test.ts b/services/platform-service/src/modules/flags/flags.test.ts index 6f8da405..33dab7aa 100644 --- a/services/platform-service/src/modules/flags/flags.test.ts +++ b/services/platform-service/src/modules/flags/flags.test.ts @@ -13,6 +13,8 @@ describe('CreateFlagSchema', () => { expect(result.data.enabled).toBe(true); expect(result.data.percentage).toBe(100); expect(result.data.platforms).toEqual([]); + expect(result.data.regions).toEqual([]); + expect(result.data.osVersions).toEqual([]); expect(result.data.segments).toEqual([]); } }); @@ -23,10 +25,43 @@ describe('CreateFlagSchema', () => { enabled: false, description: 'New dashboard UI', platforms: ['web', 'ios'], + regions: ['us', 'eu'], + osVersions: [ + { platform: 'ios', minVersion: '17.0', maxVersion: '18.0' }, + { platform: 'android', minVersion: '14.0' }, + ], segments: ['beta'], percentage: 50, }); expect(result.success).toBe(true); + if (result.success) { + expect(result.data.regions).toEqual(['us', 'eu']); + expect(result.data.osVersions).toHaveLength(2); + } + }); + + it('accepts osVersions with only minVersion', () => { + const result = CreateFlagSchema.safeParse({ + key: 'ios_only', + osVersions: [{ platform: 'ios', minVersion: '16.0' }], + }); + expect(result.success).toBe(true); + }); + + it('accepts osVersions with only maxVersion', () => { + const result = CreateFlagSchema.safeParse({ + key: 'legacy_only', + osVersions: [{ platform: 'android', maxVersion: '13.0' }], + }); + expect(result.success).toBe(true); + }); + + it('rejects osVersions with empty platform', () => { + const result = CreateFlagSchema.safeParse({ + key: 'bad_os', + osVersions: [{ platform: '' }], + }); + expect(result.success).toBe(false); }); it('rejects key with spaces', () => { @@ -70,6 +105,54 @@ describe('UpdateFlagSchema', () => { }); expect(result.success).toBe(true); }); + + it('accepts regions update', () => { + const result = UpdateFlagSchema.safeParse({ + regions: ['us', 'apac'], + }); + expect(result.success).toBe(true); + }); + + it('accepts osVersions update', () => { + const result = UpdateFlagSchema.safeParse({ + osVersions: [{ platform: 'ios', minVersion: '17.0', maxVersion: '18.0' }], + }); + expect(result.success).toBe(true); + }); +}); + +describe('compareVersions', () => { + let compareVersions: (a: string, b: string) => number; + + beforeAll(async () => { + const mod = await import('./routes.js'); + compareVersions = mod.compareVersions; + }); + + it('equal versions return 0', () => { + expect(compareVersions('17.2.1', '17.2.1')).toBe(0); + }); + + it('a < b returns -1', () => { + expect(compareVersions('16.4', '17.0')).toBe(-1); + expect(compareVersions('17.0.0', '17.0.1')).toBe(-1); + }); + + it('a > b returns 1', () => { + expect(compareVersions('18.0', '17.2.1')).toBe(1); + expect(compareVersions('17.1', '17.0.9')).toBe(1); + }); + + it('handles different segment lengths', () => { + expect(compareVersions('17.0', '17.0.0')).toBe(0); + expect(compareVersions('17', '17.0.0')).toBe(0); + expect(compareVersions('17.0.1', '17')).toBe(1); + }); + + it('handles single-segment versions', () => { + expect(compareVersions('14', '15')).toBe(-1); + expect(compareVersions('15', '14')).toBe(1); + }); }); describe('hashUserFlag (deterministic A/B assignment)', () => { diff --git a/services/platform-service/src/modules/flags/routes.ts b/services/platform-service/src/modules/flags/routes.ts index 9141b36c..2aedfbdf 100644 --- a/services/platform-service/src/modules/flags/routes.ts +++ b/services/platform-service/src/modules/flags/routes.ts @@ -30,7 +30,24 @@ function hashUserFlag(userId: string, flagKey: string): number { return (hash >>> 0) % 100; // 0-99 bucket } -export { hashUserFlag }; +/** + * Compare two dot-separated version strings (e.g. "17.2.1" vs "18.0"). + * Returns -1 if a < b, 0 if equal, 1 if a > b. + */ +function compareVersions(a: string, b: string): number { + const pa = a.split('.').map(Number); + const pb = b.split('.').map(Number); + const len = Math.max(pa.length, pb.length); + for (let i = 0; i < len; i++) { + const na = pa[i] ?? 0; + const nb = pb[i] ?? 0; + if (na < nb) return -1; + if (na > nb) return 1; + } + return 0; +} + +export { hashUserFlag, compareVersions }; export async function flagRoutes(app: FastifyInstance) { // List all flags @@ -40,15 +57,36 @@ export async function flagRoutes(app: FastifyInstance) { }); // Polling endpoint for clients - // ?userId=xxx — deterministic hash ensures same user always gets same flag assignment - // ?platform=xxx — filter flags by platform + // ?userId=xxx — deterministic hash ensures same user always gets same flag assignment + // ?platform=xxx — filter flags by platform (e.g. ios, android, web, desktop) + // ?region=xxx — filter flags by region (e.g. us, eu, apac) + // ?osVersion=xxx — filter flags by OS version (e.g. 17.2.1) app.get('/flags/poll', async req => { const productId = getRequestProductId(req); - const { platform, userId } = req.query as { platform?: string; userId?: string }; + const { platform, userId, region, osVersion } = req.query as { + platform?: string; + userId?: string; + region?: string; + osVersion?: string; + }; const all = await repo.list(productId); const active = all.filter(f => { if (!f.enabled) return false; + // Platform targeting: flag specifies which platforms it applies to if (f.platforms.length > 0 && platform && !f.platforms.includes(platform)) return false; + // Region targeting: flag specifies which regions it applies to + if (f.regions?.length > 0 && region && !f.regions.includes(region)) return false; + // OS version targeting: flag specifies version ranges per platform + if (f.osVersions?.length > 0 && platform && osVersion) { + const rule = f.osVersions.find(r => r.platform === platform); + if (rule) { + if (rule.minVersion && compareVersions(osVersion, rule.minVersion) < 0) return false; + if (rule.maxVersion && compareVersions(osVersion, rule.maxVersion) > 0) return false; + } else { + // osVersions defined but no rule for this platform — skip this flag + return false; + } + } return true; }); const flags: Record = {}; diff --git a/services/platform-service/src/modules/flags/types.ts b/services/platform-service/src/modules/flags/types.ts index a96336d0..ed8f83e8 100644 --- a/services/platform-service/src/modules/flags/types.ts +++ b/services/platform-service/src/modules/flags/types.ts @@ -5,6 +5,12 @@ import { z } from 'zod'; +export interface OsVersionRange { + platform: string; + minVersion?: string; + maxVersion?: string; +} + export interface FeatureFlagDoc { id: string; productId: string; @@ -12,12 +18,20 @@ export interface FeatureFlagDoc { enabled: boolean; description: string; platforms: string[]; + regions: string[]; + osVersions: OsVersionRange[]; segments: string[]; percentage: number; createdAt: string; updatedAt: string; } +export const OsVersionRangeSchema = z.object({ + platform: z.string().min(1), + minVersion: z.string().optional(), + maxVersion: z.string().optional(), +}); + export const CreateFlagSchema = z.object({ key: z .string() @@ -26,6 +40,8 @@ export const CreateFlagSchema = z.object({ enabled: z.boolean().default(true), description: z.string().default(''), platforms: z.array(z.string()).default([]), + regions: z.array(z.string()).default([]), + osVersions: z.array(OsVersionRangeSchema).default([]), segments: z.array(z.string()).default([]), percentage: z.number().min(0).max(100).default(100), }); @@ -34,6 +50,8 @@ export const UpdateFlagSchema = z.object({ enabled: z.boolean().optional(), description: z.string().optional(), platforms: z.array(z.string()).optional(), + regions: z.array(z.string()).optional(), + osVersions: z.array(OsVersionRangeSchema).optional(), segments: z.array(z.string()).optional(), percentage: z.number().min(0).max(100).optional(), });