@@ -30,12 +30,14 @@ import (
3030
3131// FSSDK-12813: Decision-event ID normalization tests.
3232//
33- // These tests verify the cross-SDK contract for outgoing decision events:
34- // - campaign_id is a non-empty numeric string (substitute experiment_id otherwise)
35- // - variation_id is a non-empty numeric string OR JSON null
36- // - entity_id (impression events) mirrors campaign_id byte-for-byte
37- // - rules apply uniformly to every decision type
38- // - no logging, no warning, no dropping of events on the normalization path
33+ // These tests verify the cross-SDK contract for outgoing decision events
34+ // (per the relaxed spec):
35+ // - campaign_id / entity_id: non-empty string (any character content;
36+ // opaque IDs allowed). Fallback to experiment_id ONLY when empty.
37+ // - variation_id: STRICT non-empty numeric string OR JSON null.
38+ // - entity_id (impression events) mirrors campaign_id byte-for-byte.
39+ // - rules apply uniformly to every decision type.
40+ // - no logging, no warning, no dropping of events on the normalization path.
3941
4042// --- IsNumericIDString -------------------------------------------------------
4143
@@ -77,6 +79,36 @@ func TestIsNumericIDString(t *testing.T) {
7779 }
7880}
7981
82+ // --- IsNonEmptyString --------------------------------------------------------
83+
84+ func TestIsNonEmptyString (t * testing.T ) {
85+ // Relaxed campaign_id / entity_id predicate. Only the empty string is
86+ // rejected — any other character content is accepted, including
87+ // whitespace-only, opaque IDs, and non-numeric content.
88+ cases := []struct {
89+ name string
90+ value string
91+ want bool
92+ }{
93+ {"plain digits" , "12345" , true },
94+ {"opaque layer id" , "layer_abc" , true },
95+ {"opaque default id" , "default-12345" , true },
96+ {"alpha placeholder" , "holdout_123" , true },
97+ {"single character" , "a" , true },
98+ {"whitespace only" , " " , true },
99+ {"leading zero numeric" , "0123456789" , true },
100+ {"unicode" , "१२३" , true },
101+
102+ {"empty string is invalid" , "" , false },
103+ }
104+
105+ for _ , tc := range cases {
106+ t .Run (tc .name , func (t * testing.T ) {
107+ assert .Equal (t , tc .want , IsNonEmptyString (tc .value ))
108+ })
109+ }
110+ }
111+
80112// --- NormalizeCampaignID -----------------------------------------------------
81113
82114func TestNormalizeCampaignID_ReturnsCampaignIDWhenNumeric (t * testing.T ) {
@@ -85,30 +117,42 @@ func TestNormalizeCampaignID_ReturnsCampaignIDWhenNumeric(t *testing.T) {
85117}
86118
87119func TestNormalizeCampaignID_SubstitutesExperimentIDWhenCampaignEmpty (t * testing.T ) {
88- // FR-001/FR-002: empty campaign_id is replaced with experiment_id.
120+ // FR-001/FR-002 (relaxed): empty campaign_id is replaced with experiment_id.
121+ // Fallback fires ONLY for the empty string.
89122 got := NormalizeCampaignID ("" , "15402980349" )
90123 assert .Equal (t , "15402980349" , got )
91124}
92125
93- func TestNormalizeCampaignID_SubstitutesExperimentIDWhenCampaignNonNumeric (t * testing.T ) {
126+ func TestNormalizeCampaignID_PassesThroughOpaqueCampaignID (t * testing.T ) {
127+ // Relaxed spec: opaque, non-numeric campaign IDs pass through unchanged.
128+ // IDs may be of the form "layer_abc", "default-12345", etc.
94129 got := NormalizeCampaignID ("layer_abc" , "15402980349" )
95- assert .Equal (t , "15402980349 " , got )
130+ assert .Equal (t , "layer_abc " , got )
96131}
97132
98- func TestNormalizeCampaignID_SubstitutesExperimentIDForWhitespaceCampaign (t * testing.T ) {
133+ func TestNormalizeCampaignID_PassesThroughHoldoutPlaceholder (t * testing.T ) {
134+ // Relaxed spec: alphanumeric placeholders like "holdout_123" are valid
135+ // campaign IDs and pass through; experiment_id substitution does NOT fire.
136+ got := NormalizeCampaignID ("holdout_123" , "15402980349" )
137+ assert .Equal (t , "holdout_123" , got )
138+ }
139+
140+ func TestNormalizeCampaignID_PassesThroughWhitespaceCampaign (t * testing.T ) {
141+ // Relaxed spec: whitespace-only is non-empty, so it passes through.
142+ // This is intentionally permissive — we no longer try to "fix" content.
99143 got := NormalizeCampaignID (" " , "15402980349" )
100- assert .Equal (t , "15402980349 " , got )
144+ assert .Equal (t , " " , got )
101145}
102146
103147func TestNormalizeCampaignID_AllowsLeadingZeros (t * testing.T ) {
104148 got := NormalizeCampaignID ("0123" , "9999" )
105149 assert .Equal (t , "0123" , got )
106150}
107151
108- func TestNormalizeCampaignID_PreservesMalformedExperimentIDFallback (t * testing.T ) {
109- // If both inputs are non-numeric, return experimentID as-is so we don't
110- // silently invent data . Downstream behavior for malformed datafiles is
111- // unchanged.
152+ func TestNormalizeCampaignID_PreservesExperimentIDFallback (t * testing.T ) {
153+ // When campaign_id is empty, fall back to experiment_id verbatim — even
154+ // when experiment_id itself is non-numeric . Downstream behavior for
155+ // malformed datafiles is unchanged.
112156 got := NormalizeCampaignID ("" , "exp_42" )
113157 assert .Equal (t , "exp_42" , got )
114158}
@@ -253,6 +297,33 @@ func TestImpressionEvent_NormalizesCampaignAndEntityIDsForHoldout(t *testing.T)
253297 "decisions[].campaign_id and events[].entity_id must be byte-equivalent" )
254298}
255299
300+ // TestImpressionEvent_PassesThroughOpaqueLayerID verifies the relaxed spec:
301+ // a non-numeric but non-empty LayerID (e.g. "layer_abc", "default-12345")
302+ // passes through to campaign_id and entity_id unchanged. Fallback to
303+ // experiment_id fires ONLY when LayerID is the empty string.
304+ func TestImpressionEvent_PassesThroughOpaqueLayerID (t * testing.T ) {
305+ tc := testNormConfig {}
306+ exp := entities.Experiment {Key : "exp_key" , LayerID : "default-12345" , ID : "15402980349" }
307+ variation := numericVariation ()
308+
309+ userEvent , ok := CreateImpressionUserEvent (tc , exp , & variation , newNormUserContext (),
310+ "flag_key" , exp .Key , decisionPkg .FeatureTest , true , nil )
311+ assert .True (t , ok )
312+
313+ // Opaque LayerID passes through under the relaxed spec — NOT substituted
314+ // with experiment_id.
315+ assert .Equal (t , "default-12345" , userEvent .Impression .CampaignID ,
316+ "opaque LayerID must pass through (FSSDK-12813 relaxed)" )
317+ assert .Equal (t , "default-12345" , userEvent .Impression .EntityID ,
318+ "entity_id must mirror campaign_id (FR-009)" )
319+
320+ visitor := createVisitorFromUserEvent (userEvent )
321+ assert .Equal (t , "default-12345" , visitor .Snapshots [0 ].Decisions [0 ].CampaignID )
322+ assert .Equal (t , "default-12345" , visitor .Snapshots [0 ].Events [0 ].EntityID )
323+ assert .Equal (t , visitor .Snapshots [0 ].Decisions [0 ].CampaignID ,
324+ visitor .Snapshots [0 ].Events [0 ].EntityID )
325+ }
326+
256327// TestImpressionEvent_PassesThroughNumericCampaignID verifies happy path for
257328// non-holdout decisions where LayerID is already a numeric ID.
258329func TestImpressionEvent_PassesThroughNumericCampaignID (t * testing.T ) {
0 commit comments