From e76affc82e9ddff475e6c4e208a9e7cc806955ad Mon Sep 17 00:00:00 2001 From: i-xtsu-sixyou-ken-mei <9bmf4by27w@privaterelay.appleid.com> Date: Fri, 11 Sep 2026 00:32:20 +0900 Subject: [PATCH 1/6] test(control-center): guard attribution interval endpoint --- .../social-attribution-boundary.test.ts | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 apps/control-center/src/server/services/social-attribution-boundary.test.ts 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..2a646fefd --- /dev/null +++ b/apps/control-center/src/server/services/social-attribution-boundary.test.ts @@ -0,0 +1,56 @@ +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, + }; +} From 293c362a9a0df88ff8a8cbe8227cb0f000ac6f42 Mon Sep 17 00:00:00 2001 From: i-xtsu-sixyou-ken-mei <9bmf4by27w@privaterelay.appleid.com> Date: Fri, 11 Sep 2026 01:32:23 +0900 Subject: [PATCH 2/6] test(control-center): format attribution boundary helper --- .../src/server/services/social-attribution-boundary.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 index 2a646fefd..63b844102 100644 --- a/apps/control-center/src/server/services/social-attribution-boundary.test.ts +++ b/apps/control-center/src/server/services/social-attribution-boundary.test.ts @@ -37,7 +37,10 @@ describe('social attribution interval boundaries', () => { }); }); -function observation(captured_at: string, views: number): AttributionObservation { +function observation( + captured_at: string, + views: number, +): AttributionObservation { return { social_post_id: 'post-1', captured_at, From fbfed99011a0ddff9fd82e20bdebe3c73c2582ba Mon Sep 17 00:00:00 2001 From: i-xtsu-sixyou-ken-mei <9bmf4by27w@privaterelay.appleid.com> Date: Fri, 11 Sep 2026 09:35:02 +0900 Subject: [PATCH 3/6] test(contracts): stop checking removed growth slot UI --- scripts/check-social-release-contract.mjs | 28 ----------------------- 1 file changed, 28 deletions(-) diff --git a/scripts/check-social-release-contract.mjs b/scripts/check-social-release-contract.mjs index e23a83cd3..03973b784 100644 --- a/scripts/check-social-release-contract.mjs +++ b/scripts/check-social-release-contract.mjs @@ -22,9 +22,6 @@ const languageAllocation = read( 'apps/podcast-pipeline/src/social/language-allocation.ts', ); const readme = read('apps/podcast-pipeline/src/social/README.md'); -const growthView = read( - 'apps/control-center/src/client/components/GrowthView.tsx', -); const recovery = read( 'apps/podcast-pipeline/src/social/release-cohort-store.ts', ); @@ -145,31 +142,6 @@ requireMatch( /into\s+seed_episode_id[\s\S]*limit\s+1/i, ); -const policySlotsBlock = - policy.match(/export const SOCIAL_RELEASE_SLOTS = \[([\s\S]*?)\]\s+as const/)?.[1] ?? - ''; -const policyReleaseSlots = [...policySlotsBlock.matchAll(/\{\s*hour:\s*(\d+),\s*minute:\s*(\d+)\s*\}/g)].map( - ([, hour, minute]) => `${hour.padStart(2, '0')}:${minute.padStart(2, '0')}`, -); -const growthSlotsBlock = - growthView.match(/CURRENT_RELEASE_SLOTS_JST = \[([^\]]+)\]/)?.[1] ?? ''; -const growthReleaseSlots = [...growthSlotsBlock.matchAll(/'(\d{2}:\d{2})'/g)].map( - ([, slot]) => slot, -); -const dailyCap = Number( - policy.match(/SOCIAL_RELEASE_DAILY_CAP\s*=\s*(\d+)/)?.[1] ?? Number.NaN, -); -if (JSON.stringify(policyReleaseSlots) !== JSON.stringify(growthReleaseSlots)) { - failures.push( - `Control Center GrowthView release slots ${JSON.stringify(growthReleaseSlots)} do not match policy ${JSON.stringify(policyReleaseSlots)}`, - ); -} -if (dailyCap !== growthReleaseSlots.length) { - failures.push( - `Control Center GrowthView exposes ${growthReleaseSlots.length} slots but SOCIAL_RELEASE_DAILY_CAP is ${dailyCap}`, - ); -} - forbidMatch('daemon', daemon, /enqueuePlatformCohort/); forbidMatch('daemon', daemon, /platformBudgetIndex/); forbidMatch('daemon', daemon, /nextBudgetSlot/); From d81fcda21b838568da3ed89a53f658f09253a4a2 Mon Sep 17 00:00:00 2001 From: i-xtsu-sixyou-ken-mei <9bmf4by27w@privaterelay.appleid.com> Date: Fri, 11 Sep 2026 09:35:26 +0900 Subject: [PATCH 4/6] test(contracts): preserve release slot policy invariant --- scripts/check-social-release-contract.mjs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/scripts/check-social-release-contract.mjs b/scripts/check-social-release-contract.mjs index 03973b784..ec3384925 100644 --- a/scripts/check-social-release-contract.mjs +++ b/scripts/check-social-release-contract.mjs @@ -142,6 +142,21 @@ requireMatch( /into\s+seed_episode_id[\s\S]*limit\s+1/i, ); +const policySlotsBlock = + policy.match(/export const SOCIAL_RELEASE_SLOTS = \[([\s\S]*?)\]\s+as const/)?.[1] ?? + ''; +const policyReleaseSlots = [...policySlotsBlock.matchAll(/\{\s*hour:\s*(\d+),\s*minute:\s*(\d+)\s*\}/g)].map( + ([, hour, minute]) => `${hour.padStart(2, '0')}:${minute.padStart(2, '0')}`, +); +const dailyCap = Number( + policy.match(/SOCIAL_RELEASE_DAILY_CAP\s*=\s*(\d+)/)?.[1] ?? Number.NaN, +); +if (dailyCap !== policyReleaseSlots.length) { + failures.push( + `Social release policy exposes ${policyReleaseSlots.length} slots but SOCIAL_RELEASE_DAILY_CAP is ${dailyCap}`, + ); +} + forbidMatch('daemon', daemon, /enqueuePlatformCohort/); forbidMatch('daemon', daemon, /platformBudgetIndex/); forbidMatch('daemon', daemon, /nextBudgetSlot/); From 49ade3fc50241470faa64e356c2e7b8fb2c7a57b Mon Sep 17 00:00:00 2001 From: i-xtsu-sixyou-ken-mei <9bmf4by27w@privaterelay.appleid.com> Date: Fri, 11 Sep 2026 10:36:14 +0900 Subject: [PATCH 5/6] chore(test-qa): align release contract with main --- scripts/check-social-release-contract.mjs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/scripts/check-social-release-contract.mjs b/scripts/check-social-release-contract.mjs index ec3384925..10a3609b5 100644 --- a/scripts/check-social-release-contract.mjs +++ b/scripts/check-social-release-contract.mjs @@ -22,6 +22,9 @@ const languageAllocation = read( 'apps/podcast-pipeline/src/social/language-allocation.ts', ); const readme = read('apps/podcast-pipeline/src/social/README.md'); +const growthView = read( + 'apps/control-center/src/client/pages/GrowthPage.tsx', +); const recovery = read( 'apps/podcast-pipeline/src/social/release-cohort-store.ts', ); @@ -148,12 +151,22 @@ const policySlotsBlock = const policyReleaseSlots = [...policySlotsBlock.matchAll(/\{\s*hour:\s*(\d+),\s*minute:\s*(\d+)\s*\}/g)].map( ([, hour, minute]) => `${hour.padStart(2, '0')}:${minute.padStart(2, '0')}`, ); +const growthSlotsBlock = + growthView.match(/CURRENT_RELEASE_SLOTS_JST = \[([^\]]+)\]/)?.[1] ?? ''; +const growthReleaseSlots = [...growthSlotsBlock.matchAll(/'(\d{2}:\d{2})'/g)].map( + ([, slot]) => slot, +); const dailyCap = Number( policy.match(/SOCIAL_RELEASE_DAILY_CAP\s*=\s*(\d+)/)?.[1] ?? Number.NaN, ); -if (dailyCap !== policyReleaseSlots.length) { +if (JSON.stringify(policyReleaseSlots) !== JSON.stringify(growthReleaseSlots)) { + failures.push( + `Control Center GrowthView release slots ${JSON.stringify(growthReleaseSlots)} do not match policy ${JSON.stringify(policyReleaseSlots)}`, + ); +} +if (dailyCap !== growthReleaseSlots.length) { failures.push( - `Social release policy exposes ${policyReleaseSlots.length} slots but SOCIAL_RELEASE_DAILY_CAP is ${dailyCap}`, + `Control Center GrowthView exposes ${growthReleaseSlots.length} slots but SOCIAL_RELEASE_DAILY_CAP is ${dailyCap}`, ); } From 39a19f099cbe09bfa3215518a13494e1f9ed3c4e Mon Sep 17 00:00:00 2001 From: i-xtsu-sixyou-ken-mei <9bmf4by27w@privaterelay.appleid.com> Date: Fri, 11 Sep 2026 10:37:34 +0900 Subject: [PATCH 6/6] test(control-center): isolate 24h growth window --- .../social-growth-window-filter.test.ts | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 apps/control-center/src/server/services/social-growth-window-filter.test.ts 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; +}