Skip to content

Commit 7babd8c

Browse files
authored
[AI-FSSDK] [FSSDK-12834] Replace non-numeric holdout placeholder IDs in event-construction tests (#646)
* [AI-FSSDK] [FSSDK-12834] Replace non-numeric holdout placeholder IDs in event-construction tests * [AI-FSSDK] [FSSDK-12834] Refine FR-011 inline comments for relaxed campaign_id contract * [AI-FSSDK] [FSSDK-12834] Relax campaign_id/entity_id validation to any non-empty string * [FSSDK-12834] Strip ticket refs and tighten comments Drop FSSDK-12813 / FSSDK-12834 references from production and test code (ticket IDs belong in commit messages and PR titles, not source). Tighten the verbose multi-line docstrings down to one short line each or remove them where the code is self-explanatory. No behavior change.
1 parent 3ab25b6 commit 7babd8c

7 files changed

Lines changed: 133 additions & 77 deletions

Sources/Implementation/Events/BatchEventBuilder.swift

Lines changed: 22 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -62,30 +62,23 @@ class BatchEventBuilder {
6262
dispatchEvents: [dispatchEvent])
6363
}
6464

65-
// MARK: - Decision Event ID Normalization (FSSDK-12813)
65+
// MARK: - Decision Event ID Normalization
6666

67-
/// Normalizes `campaign_id` and `variation_id` so every dispatched decision
68-
/// event carries pipeline-valid values, regardless of decision type
69-
/// (experiment, feature test, rollout, or holdout). See spec FR-001–FR-005.
67+
/// Normalizes `campaign_id` and `variation_id` for every dispatched
68+
/// decision event (experiment, feature test, rollout, holdout).
7069
///
71-
/// - `campaign_id` falls back to `experiment_id` when the raw value is not a
72-
/// non-empty decimal-digit string. For well-formed datafiles this is a
73-
/// no-op for non-holdout decisions; the fallback fires on holdout events
74-
/// (which legitimately may lack `layerId`) and acts as a safety net for
75-
/// any future malformed input.
76-
/// - `variation_id` becomes `nil` (emitted as JSON `null`) when the raw
77-
/// value is not a non-empty decimal-digit string.
70+
/// - `campaign_id` (mirrored as `events[].entity_id`) accepts any non-empty
71+
/// string and falls back to `experiment_id` only on empty/whitespace
72+
/// input. Opaque IDs (e.g. `"layer_abc"`) pass through.
73+
/// - `variation_id` must be a non-empty decimal-digit string; anything
74+
/// else becomes `nil` (encoded as JSON `null`).
75+
/// - `experiment_id` is never validated here — it is always passed
76+
/// through so the event is never dropped.
7877
static func normalizeDecisionIds(rawCampaignId: String,
7978
rawVariationId: String?,
8079
experimentId: String) -> (campaignId: String, variationId: String?) {
81-
// campaign_id must be a numeric string, otherwise fall back to
82-
// experiment_id. The upstream experiment_id is passed through unchanged
83-
// even if it is itself invalid (FR-006 — never drop the event).
84-
let campaignId = isValidNumericIdString(rawCampaignId) ? rawCampaignId : experimentId
80+
let campaignId = isValidStringId(rawCampaignId) ? rawCampaignId : experimentId
8581

86-
// variation_id must be a numeric string or JSON null. Anything else
87-
// (empty, whitespace, non-numeric) becomes nil so the encoder emits
88-
// JSON `null`.
8982
let variationId: String?
9083
if let raw = rawVariationId, isValidNumericIdString(raw) {
9184
variationId = raw
@@ -96,16 +89,19 @@ class BatchEventBuilder {
9689
return (campaignId, variationId)
9790
}
9891

99-
/// Returns true iff `value` is a non-empty string consisting entirely of
100-
/// decimal digits `[0-9]`. Empty strings, whitespace-only strings, and any
101-
/// string containing non-digit characters return false. Leading zeros are
102-
/// allowed because Optimizely IDs are opaque identifiers (see spec).
92+
/// True iff `value` is non-empty and contains at least one non-whitespace
93+
/// character. Used for the relaxed `campaign_id` / `entity_id` contract.
94+
static func isValidStringId(_ value: String) -> Bool {
95+
guard !value.isEmpty else { return false }
96+
return value.contains { !$0.isWhitespace }
97+
}
98+
99+
/// True iff `value` is a non-empty ASCII decimal-digit string `[0-9]+`.
100+
/// Used for the strict `variation_id` contract. We walk unicode scalars
101+
/// directly because `CharacterSet.decimalDigits` would also accept
102+
/// non-ASCII digit forms (e.g. Arabic-Indic).
103103
static func isValidNumericIdString(_ value: String) -> Bool {
104104
guard !value.isEmpty else { return false }
105-
// `CharacterSet.decimalDigits` includes non-ASCII digit forms (e.g.
106-
// Arabic-Indic digits). Spec restricts validity to ASCII `[0-9]`, so we
107-
// walk the unicode scalars explicitly instead of using
108-
// `rangeOfCharacter(from: CharacterSet.decimalDigits.inverted)`.
109105
for scalar in value.unicodeScalars {
110106
if scalar.value < 0x30 || scalar.value > 0x39 {
111107
return false

Tests/OptimizelyTests-Common/BatchEventBuilderTests_Events.swift

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
//
2-
// Copyright 2019-2021, 2023 Optimizely, Inc. and contributors
2+
// Copyright 2019-2021, 2023, 2026 Optimizely, Inc. and contributors
33
//
44
// Licensed under the Apache License, Version 2.0 (the "License");
55
// you may not use this file except in compliance with the License.
@@ -27,19 +27,22 @@ class BatchEventBuilderTests_Events: XCTestCase {
2727
var project: Project!
2828
let datafile = OTUtils.loadJSONDatafile("api_datafile")!
2929

30+
// Numeric strings used for both id fields: campaign_id accepts any
31+
// non-empty string, variation_id requires digits-only. Numeric satisfies
32+
// both and keeps fixtures uniform.
3033
var sampleHoldout: [String: Any] {
3134
return [
3235
"status": "Running",
33-
"id": "holdout_4444444",
36+
"id": "4444444",
3437
"key": "holdout_key",
3538
"trafficAllocation": [
36-
["entityId": "holdout_variation_a11", "endOfRange": 10000] // 100% traffic allocation
39+
["entityId": "4444411", "endOfRange": 10000] // 100% traffic allocation
3740
],
3841
"audienceIds": [],
3942
"variations": [
4043
[
4144
"variables": [],
42-
"id": "holdout_variation_a11",
45+
"id": "4444411",
4346
"key": "holdout_a"
4447
]
4548
]

Tests/OptimizelyTests-Common/BatchEventBuilderTests_HoldoutIds.swift

Lines changed: 75 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -14,21 +14,43 @@
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

2222
import XCTest
2323

2424
class 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
}

Tests/OptimizelyTests-Common/DecisionListenerTest_Holdouts.swift

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
//
2-
// Copyright 2022, Optimizely, Inc. and contributors
2+
// Copyright 2022, 2026, Optimizely, Inc. and contributors
33
//
44
// Licensed under the Apache License, Version 2.0 (the "License");
55
// you may not use this file except in compliance with the License.
@@ -39,20 +39,23 @@ class DecisionListenerTests_Holdouts: XCTestCase {
3939
let kVariableValueDouble = 4.2
4040
let kVariableValueBool = true
4141

42+
// variation id and trafficAllocation entityId must be numeric strings
43+
// (variation_id contract). Holdout id uses numeric too for fixture
44+
// uniformity, though any non-empty string would satisfy campaign_id.
4245
var sampleHoldout: [String: Any] {
4346
return [
4447
"status": "Running",
45-
"id": "id_holdout",
48+
"id": "9999900001",
4649
"key": "key_holdout",
4750
"layerId": "10420273888",
4851
"trafficAllocation": [
49-
["entityId": "id_holdout_variation", "endOfRange": 500]
52+
["entityId": "9999900002", "endOfRange": 500]
5053
],
5154
"audienceIds": [],
5255
"variations": [
5356
[
5457
"variables": [],
55-
"id": "id_holdout_variation",
58+
"id": "9999900002",
5659
"key": "key_holdout_variation"
5760
]
5861
]

Tests/OptimizelyTests-Common/DecisionServiceTests_LocalHoldouts.swift

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,19 +30,21 @@ class DecisionServiceTests_LocalHoldouts: XCTestCase {
3030
let experimentRuleId = "10390977673" // From decide_datafile
3131
let deliveryRuleId = "3332020515" // From decide_datafile rollout
3232

33+
// variation id and trafficAllocation entityId must be numeric strings.
34+
// Holdout id uses numeric too for fixture uniformity. Keys are arbitrary.
3335
var sampleHoldout: [String: Any] {
3436
return [
3537
"status": "Running",
36-
"id": "holdout_test_id",
38+
"id": "9999900010",
3739
"key": "holdout_test_key",
3840
"trafficAllocation": [
39-
["entityId": "holdout_variation_id", "endOfRange": 5000] // 50% traffic
41+
["entityId": "9999900020", "endOfRange": 5000] // 50% traffic
4042
],
4143
"audienceIds": [],
4244
"variations": [
4345
[
4446
"variables": [],
45-
"id": "holdout_variation_id",
47+
"id": "9999900020",
4648
"key": "holdout_variation_key",
4749
"featureEnabled": false
4850
]

0 commit comments

Comments
 (0)