diff --git a/apps/control-center/src/server/services/social-attribution-boundary.test.ts b/apps/control-center/src/server/services/social-attribution-boundary.test.ts new file mode 100644 index 000000000..63b844102 --- /dev/null +++ b/apps/control-center/src/server/services/social-attribution-boundary.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; + +import { + computePostActivity, + type AttributionObservation, + type SnapshotInterval, +} from './social-attribution.js'; + +const interval: SnapshotInterval = { + platform: 'x', + startAt: '2026-08-01T00:00:00.000Z', + endAt: '2026-08-01T03:00:00.000Z', + followersStart: 10, + followersEnd: 14, +}; + +describe('social attribution interval boundaries', () => { + it('does not leak a future observation into the current interval', () => { + expect( + computePostActivity({ + interval, + post: { + id: 'post-1', + platform: 'x', + published_at: '2026-07-31T20:00:00.000Z', + }, + observations: [ + observation(interval.startAt, 100), + observation(interval.endAt, 140), + observation('2026-08-01T04:00:00.000Z', 1_000), + ], + }), + ).toMatchObject({ + postId: 'post-1', + deltaReach: 40, + }); + }); +}); + +function observation( + captured_at: string, + views: number, +): AttributionObservation { + return { + social_post_id: 'post-1', + captured_at, + age_hours: 1, + measurement_window: null, + collection_status: 'collected', + views, + impressions: null, + likes: null, + comments: null, + shares: null, + saves: null, + profile_visits: null, + followers_gained: null, + }; +} diff --git a/apps/control-center/src/server/services/social-growth-window-filter.test.ts b/apps/control-center/src/server/services/social-growth-window-filter.test.ts new file mode 100644 index 000000000..93f6da2b1 --- /dev/null +++ b/apps/control-center/src/server/services/social-growth-window-filter.test.ts @@ -0,0 +1,123 @@ +import type { createClient } from '@supabase/supabase-js'; +import { describe, expect, it } from 'vitest'; + +import { readControlCenterConfig } from '../config/env.js'; +import { loadSocialGrowth } from './social-growth.js'; + +const NOW = new Date('2026-09-11T00:00:00.000Z'); +const CONFIGURED = readControlCenterConfig({ + SUPABASE_URL: 'https://example.supabase.co', + SUPABASE_SERVICE_ROLE_KEY: 'service-role-key', +}); + +describe('loadSocialGrowth standardized metric windows', () => { + it('does not let later 72h metrics inflate the 24h experiment sample', async () => { + const posts = [ + { + id: 'post-1', + episode_id: 'episode-1', + platform: 'threads', + language_code: 'ja', + published_at: '2026-09-10T00:00:00.000Z', + experiment_key: 'window-v1', + experiment_variant: 'control', + content_features: null, + }, + ]; + const metrics = [ + metric('24h', '2026-09-11T00:00:00.000Z', 100, 10), + metric('72h', '2026-09-13T00:00:00.000Z', 9_999, 999), + ]; + + const response = await loadSocialGrowth({ + config: CONFIGURED, + now: NOW, + createSupabaseClient: clientFactory({ posts, metrics }), + }); + + const experiment = response.experiments.find( + (row) => row.experimentKey === 'window-v1', + ); + const arm = experiment?.arms.find((row) => row.variant === 'control'); + + expect(response.status).toBe('ok'); + expect(arm).toMatchObject({ + samples24h: 1, + medianReach24h: 100, + meanReach24h: 100, + medianEngagementRate: 0.1, + }); + }); +}); + +function metric( + measurementWindow: string, + capturedAt: string, + views: number, + likes: number, +) { + return { + social_post_id: 'post-1', + captured_at: capturedAt, + age_hours: measurementWindow === '24h' ? 24 : 72, + measurement_window: measurementWindow, + collection_status: 'collected', + views, + impressions: null, + likes, + comments: 0, + shares: 0, + saves: 0, + profile_visits: null, + followers_gained: null, + }; +} + +function clientFactory(input: { + posts: Array>; + metrics: ReturnType[]; +}) { + const client = { + from(table: string) { + const rows = + table === 'social_posts' + ? input.posts + : table === 'social_post_metrics' + ? input.metrics + : []; + const chain = { + select() { + return chain; + }, + gte() { + return chain; + }, + lte() { + return chain; + }, + not() { + return chain; + }, + eq() { + return chain; + }, + order() { + return chain; + }, + limit() { + return Promise.resolve({ data: rows, count: rows.length, error: null }); + }, + then(resolve: (value: { data: typeof rows; count: number; error: null }) => unknown) { + return Promise.resolve({ data: rows, count: rows.length, error: null }).then( + resolve, + ); + }, + }; + return chain; + }, + schema() { + return client; + }, + }; + return (() => client) as unknown as typeof createClient; +} diff --git a/scripts/check-social-release-contract.mjs b/scripts/check-social-release-contract.mjs index e23a83cd3..10a3609b5 100644 --- a/scripts/check-social-release-contract.mjs +++ b/scripts/check-social-release-contract.mjs @@ -23,7 +23,7 @@ const languageAllocation = read( ); const readme = read('apps/podcast-pipeline/src/social/README.md'); const growthView = read( - 'apps/control-center/src/client/components/GrowthView.tsx', + 'apps/control-center/src/client/pages/GrowthPage.tsx', ); const recovery = read( 'apps/podcast-pipeline/src/social/release-cohort-store.ts',