1414// limitations under the License.
1515//
1616
17- // Tests for FSSDK-12813: every decision event (experiment, feature test,
18- // rollout, or holdout) must carry a valid numeric `campaign_id` (falling back
19- // to `experiment_id` if invalid) and either a valid numeric `variation_id` or
20- // JSON `null`. The normalization is uniform across decision types per FR-005 .
17+ // Tests for decision- event id normalization:
18+ // campaign_id / events[].entity_id: any non-empty string; fallback to
19+ // experiment_id on empty / whitespace / null.
20+ // variation_id: decimal-digit string only; otherwise JSON null .
2121
2222import XCTest
2323
2424class BatchEventBuilderTests_HoldoutIds : XCTestCase {
2525
26- // MARK: - isValidNumericIdString
26+ // MARK: - isValidStringId (relaxed contract for campaign_id / entity_id)
27+
28+ func testIsValidStringId_nonEmptyContent( ) {
29+ XCTAssertTrue ( BatchEventBuilder . isValidStringId ( " 12345 " ) )
30+ XCTAssertTrue ( BatchEventBuilder . isValidStringId ( " default-12345 " ) )
31+ XCTAssertTrue ( BatchEventBuilder . isValidStringId ( " layer_abc " ) )
32+ XCTAssertTrue ( BatchEventBuilder . isValidStringId ( " a " ) )
33+ XCTAssertTrue ( BatchEventBuilder . isValidStringId ( " 0 " ) )
34+ XCTAssertTrue ( BatchEventBuilder . isValidStringId ( " not_a_number " ) )
35+ // Non-ASCII content is accepted; no character-set restriction.
36+ XCTAssertTrue ( BatchEventBuilder . isValidStringId ( " \u{0660} \u{0661} " ) )
37+ }
38+
39+ func testIsValidStringId_emptyOrWhitespaceOnly( ) {
40+ XCTAssertFalse ( BatchEventBuilder . isValidStringId ( " " ) )
41+ XCTAssertFalse ( BatchEventBuilder . isValidStringId ( " " ) )
42+ XCTAssertFalse ( BatchEventBuilder . isValidStringId ( " " ) )
43+ XCTAssertFalse ( BatchEventBuilder . isValidStringId ( " \t " ) )
44+ XCTAssertFalse ( BatchEventBuilder . isValidStringId ( " \n " ) )
45+ XCTAssertFalse ( BatchEventBuilder . isValidStringId ( " \t \n " ) )
46+ }
47+
48+ // MARK: - isValidNumericIdString (strict contract for variation_id)
2749
2850 func testIsValidNumericIdString_validDigits( ) {
2951 XCTAssertTrue ( BatchEventBuilder . isValidNumericIdString ( " 12345 " ) )
3052 XCTAssertTrue ( BatchEventBuilder . isValidNumericIdString ( " 0 " ) )
31- // Leading zeros are allowed because IDs are opaque.
53+ // Leading zeros allowed: IDs are opaque.
3254 XCTAssertTrue ( BatchEventBuilder . isValidNumericIdString ( " 007 " ) )
3355 XCTAssertTrue ( BatchEventBuilder . isValidNumericIdString ( " 10390977714 " ) )
3456 }
@@ -49,14 +71,13 @@ class BatchEventBuilderTests_HoldoutIds: XCTestCase {
4971 XCTAssertFalse ( BatchEventBuilder . isValidNumericIdString ( " +12345 " ) )
5072 XCTAssertFalse ( BatchEventBuilder . isValidNumericIdString ( " 12 45 " ) )
5173 XCTAssertFalse ( BatchEventBuilder . isValidNumericIdString ( " 1e10 " ) )
52- // Non-ASCII digit forms (e.g. Arabic-Indic) are NOT valid per spec .
74+ // Non-ASCII digit forms (e.g. Arabic-Indic) are NOT valid.
5375 XCTAssertFalse ( BatchEventBuilder . isValidNumericIdString ( " \u{0660} \u{0661} " ) )
5476 }
5577
56- // MARK: - normalizeDecisionIds — valid IDs pass through unchanged (SC-003)
78+ // MARK: - normalizeDecisionIds — happy path
5779
5880 func testNormalize_validCampaignAndVariation_passThrough( ) {
59- // The dominant production case: both IDs already valid. No change.
6081 let ( campaign, variation) = BatchEventBuilder . normalizeDecisionIds (
6182 rawCampaignId: " 555 " ,
6283 rawVariationId: " 777 " ,
@@ -66,8 +87,6 @@ class BatchEventBuilderTests_HoldoutIds: XCTestCase {
6687 }
6788
6889 func testNormalize_validCampaignAndNilVariation_variationStaysNil( ) {
69- // Valid campaign, no variation supplied (e.g. holdout with no variation
70- // assigned). variation_id must be JSON null, not empty string.
7190 let ( campaign, variation) = BatchEventBuilder . normalizeDecisionIds (
7291 rawCampaignId: " 555 " ,
7392 rawVariationId: nil ,
@@ -76,30 +95,47 @@ class BatchEventBuilderTests_HoldoutIds: XCTestCase {
7695 XCTAssertNil ( variation)
7796 }
7897
79- // MARK: - normalizeDecisionIds — campaign_id (FR-001/FR-002) applied uniformly
98+ // MARK: - normalizeDecisionIds — campaign_id
8099
81100 func testNormalize_emptyCampaignFallsBackToExperimentId( ) {
101+ // Canonical holdout case: layerId defaults to "".
82102 let ( campaign, _) = BatchEventBuilder . normalizeDecisionIds (
83103 rawCampaignId: " " ,
84104 rawVariationId: " 777 " ,
85105 experimentId: " exp_42 " )
86106 XCTAssertEqual ( campaign, " exp_42 " )
87107 }
88108
89- func testNormalize_whitespaceCampaignFallsBackToExperimentId ( ) {
109+ func testNormalize_whitespaceOnlyCampaignFallsBackToExperimentId ( ) {
90110 let ( campaign, _) = BatchEventBuilder . normalizeDecisionIds (
91111 rawCampaignId: " " ,
92112 rawVariationId: " 777 " ,
93113 experimentId: " exp_42 " )
94114 XCTAssertEqual ( campaign, " exp_42 " )
95115 }
96116
97- func testNormalize_nonNumericCampaignFallsBackToExperimentId( ) {
117+ func testNormalize_opaqueCampaignPassesThroughUnchanged( ) {
118+ let ( campaign, _) = BatchEventBuilder . normalizeDecisionIds (
119+ rawCampaignId: " default-12345 " ,
120+ rawVariationId: " 777 " ,
121+ experimentId: " exp_42 " )
122+ XCTAssertEqual ( campaign, " default-12345 " )
123+ }
124+
125+ func testNormalize_nonNumericCampaignPassesThroughUnchanged( ) {
98126 let ( campaign, _) = BatchEventBuilder . normalizeDecisionIds (
99127 rawCampaignId: " not_a_number " ,
100128 rawVariationId: " 777 " ,
101129 experimentId: " exp_42 " )
102- XCTAssertEqual ( campaign, " exp_42 " )
130+ XCTAssertEqual ( campaign, " not_a_number " )
131+ }
132+
133+ func testNormalize_opaquePrefixedLayerIdPassesThroughUnchanged( ) {
134+ let ( campaign, _) = BatchEventBuilder . normalizeDecisionIds (
135+ rawCampaignId: " layer_abc123 " ,
136+ rawVariationId: " 777 " ,
137+ experimentId: " exp_42 " )
138+ XCTAssertEqual ( campaign, " layer_abc123 " )
103139 }
104140
105141 func testNormalize_validNumericCampaignKept( ) {
@@ -111,16 +147,15 @@ class BatchEventBuilderTests_HoldoutIds: XCTestCase {
111147 }
112148
113149 func testNormalize_invalidExperimentIdStillPassedThrough( ) {
114- // FR-006: never drop the event; pass invalid experiment_id through if
115- // that is all we have for the fallback.
150+ // Never drop the event — pass the invalid fallback through.
116151 let ( campaign, _) = BatchEventBuilder . normalizeDecisionIds (
117152 rawCampaignId: " " ,
118153 rawVariationId: " 777 " ,
119154 experimentId: " " )
120155 XCTAssertEqual ( campaign, " " )
121156 }
122157
123- // MARK: - normalizeDecisionIds — variation_id (FR-003/FR-004) applied uniformly
158+ // MARK: - normalizeDecisionIds — variation_id
124159
125160 func testNormalize_emptyVariationBecomesNil( ) {
126161 let ( _, variation) = BatchEventBuilder . normalizeDecisionIds (
@@ -180,14 +215,13 @@ class BatchEventBuilderTests_HoldoutIds: XCTestCase {
180215 let data = try JSONEncoder ( ) . encode ( decision)
181216 let json = try JSONSerialization . jsonObject ( with: data, options: . allowFragments) as! [ String : Any ]
182217
183- // JSONSerialization surfaces JSON `null` as `NSNull`. Verify the key is
184- // PRESENT with an explicit null (not omitted by encodeIfPresent ).
218+ // JSONSerialization surfaces JSON `null` as `NSNull`. The key must be
219+ // present with explicit null (not omitted).
185220 XCTAssertTrue ( json. keys. contains ( " variation_id " ) ,
186221 " variation_id must be present in the JSON payload " )
187222 XCTAssertTrue ( json [ " variation_id " ] is NSNull ,
188223 " variation_id must serialize as explicit JSON null when nil " )
189224
190- // Sanity-check other fields are unchanged.
191225 XCTAssertEqual ( json [ " campaign_id " ] as? String , " 12345 " )
192226 XCTAssertEqual ( json [ " experiment_id " ] as? String , " 67890 " )
193227 }
@@ -212,25 +246,23 @@ class BatchEventBuilderTests_HoldoutIds: XCTestCase {
212246 XCTAssertFalse ( json [ " variation_id " ] is NSNull )
213247 }
214248
215- // MARK: - Impression event entity_id (FR-009 / SC-006)
249+ // MARK: - Impression event entity_id
216250
217251 /// End-to-end: a holdout impression event has an empty source `layerId`,
218252 /// so both `decisions[0].campaign_id` AND `events[0].entity_id` must fall
219253 /// back to the holdout's `experiment_id`, and they must be byte-equal.
220254 func testImpressionEvent_holdout_entityIdMirrorsNormalizedCampaignId( ) throws {
221255 let datafile = OTUtils . loadJSONDatafile ( " api_datafile " ) !
222256 let eventDispatcher = MockEventDispatcher ( )
223- var optimizely : OptimizelyClient ! = OptimizelyClient ( sdkKey: " fssdk_12813_entity_id " ,
257+ var optimizely : OptimizelyClient ! = OptimizelyClient ( sdkKey: " holdout_entity_id_test " ,
224258 eventDispatcher: eventDispatcher)
225259 defer {
226260 optimizely? . close ( )
227261 optimizely = nil
228262 }
229263 try optimizely. start ( datafile: datafile)
230264
231- // Holdout struct defaults layerId to "" (see Holdout.swift) — the
232- // canonical case this fix targets. id is the fallback used by both
233- // campaign_id (FR-002) and entity_id (FR-009).
265+ // Holdout defaults layerId to "" — the canonical case this targets.
234266 let holdoutJSON : [ String : Any ] = [
235267 " status " : " Running " ,
236268 " id " : " holdout_4444444 " ,
@@ -265,14 +297,28 @@ class BatchEventBuilderTests_HoldoutIds: XCTestCase {
265297 let campaignId = decision [ " campaign_id " ] as? String
266298 let entityId = dispatchEvent [ " entity_id " ] as? String
267299
268- // FR-002: campaign_id falls back to experiment_id (holdout.id).
269300 XCTAssertEqual ( campaignId, holdout. id,
270301 " campaign_id must fall back to holdout.id when layerId is empty " )
271- // FR-009: entity_id falls back the same way.
272302 XCTAssertEqual ( entityId, holdout. id,
273303 " entity_id must fall back to holdout.id when layerId is empty " )
274- // SC-006: the two fields share source + fallback ; they must never diverge.
304+ // entity_id mirrors normalized campaign_id ; they must never diverge.
275305 XCTAssertEqual ( campaignId, entityId,
276306 " campaign_id and entity_id must hold the same normalized value " )
277307 }
308+
309+ /// Opaque non-numeric source passes through normalization unchanged.
310+ /// entity_id is assigned from the normalized campaign_id in
311+ /// createImpressionEvent, so proving campaign_id passthrough also proves
312+ /// entity_id passthrough on the wire.
313+ func testNormalize_opaqueLayerIdPassesThroughForBothCampaignAndEntity( ) {
314+ let opaqueLayerId = " layer_abc123 "
315+ let ( campaignId, variationId) = BatchEventBuilder . normalizeDecisionIds (
316+ rawCampaignId: opaqueLayerId,
317+ rawVariationId: " 777 " ,
318+ experimentId: " exp_999 " )
319+ XCTAssertEqual ( campaignId, opaqueLayerId,
320+ " opaque layerId must pass through to campaign_id unchanged " )
321+ XCTAssertEqual ( variationId, " 777 " ,
322+ " valid numeric variation_id must pass through unchanged " )
323+ }
278324}
0 commit comments