diff --git a/Demo/ConvertSwiftSDKDemo/ConvertSwiftSDKDemo.xcodeproj/project.pbxproj b/Demo/ConvertSwiftSDKDemo/ConvertSwiftSDKDemo.xcodeproj/project.pbxproj index aa6044d..afa6fe4 100644 --- a/Demo/ConvertSwiftSDKDemo/ConvertSwiftSDKDemo.xcodeproj/project.pbxproj +++ b/Demo/ConvertSwiftSDKDemo/ConvertSwiftSDKDemo.xcodeproj/project.pbxproj @@ -32,6 +32,7 @@ EC9FC27BB45ECFF34FF06369 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = "TEMP_8D50EC2F-E11F-4D4C-9713-4E057F4F075F" /* Assets.xcassets */; }; DEC0DE000000000000000001 /* demo-config.json in Resources */ = {isa = PBXBuildFile; fileRef = DEC0DE000000000000000002 /* demo-config.json */; }; FF6DFE9D2E966A8221A5DCE3 /* ConfigState.swift in Sources */ = {isa = PBXBuildFile; fileRef = B26FB3ECD39557B4BD82B372 /* ConfigState.swift */; }; + DEC0DE000000000000000004 /* DemoViewModel+Preview.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEC0DE000000000000000005 /* DemoViewModel+Preview.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -61,6 +62,8 @@ "TEMP_8D50EC2F-E11F-4D4C-9713-4E057F4F075F" /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; DEC0DE000000000000000002 /* demo-config.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "demo-config.json"; sourceTree = ""; }; "TEMP_CA83CA2C-B089-4A16-B89C-7E997C2ED50B" /* ConvertSwiftSDKDemoApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConvertSwiftSDKDemoApp.swift; sourceTree = ""; }; + DEC0DE000000000000000003 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + DEC0DE000000000000000005 /* DemoViewModel+Preview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DemoViewModel+Preview.swift"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -141,6 +144,7 @@ 32A7AB9721D664AE01FD4F2F /* DemoViewModel+Config.swift */, 1DF1D7B4E7C17D5D60315AD8 /* DemoViewModel+Conversions.swift */, 0F070E645EA5862EAF753FDF /* DemoViewModel+Inspector.swift */, + DEC0DE000000000000000005 /* DemoViewModel+Preview.swift */, 532237ECDD6A745AABAFEA65 /* InspectorEvent.swift */, ); path = Model; @@ -164,6 +168,7 @@ children = ( "TEMP_8D50EC2F-E11F-4D4C-9713-4E057F4F075F" /* Assets.xcassets */, DEC0DE000000000000000002 /* demo-config.json */, + DEC0DE000000000000000003 /* Info.plist */, "TEMP_1BF71A44-8E85-4D7F-A9EB-CE4EF34828DF" /* ContentView.swift */, "TEMP_CA83CA2C-B089-4A16-B89C-7E997C2ED50B" /* ConvertSwiftSDKDemoApp.swift */, 2BB4597078C45D14D92BC818 /* Tabs */, @@ -259,6 +264,7 @@ 4C81DFDB24B0026CCFCCA5D7 /* DemoViewModel+Config.swift in Sources */, 9BBEC7FE8D8B26F102D3E669 /* DemoViewModel+Conversions.swift in Sources */, 9C4E5395614537BF82D4DB47 /* DemoViewModel+Inspector.swift in Sources */, + DEC0DE000000000000000004 /* DemoViewModel+Preview.swift in Sources */, 5ABB54AE4D3E168525C35056 /* DemoViewModel.swift in Sources */, 0ADAFCE3988C3032083647DA /* EmptyStateView.swift in Sources */, EA3FB463D99BCFED14D7199E /* EventInspectorSheet.swift in Sources */, @@ -403,9 +409,8 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = BrandAccent; CODE_SIGN_IDENTITY = "iPhone Developer"; CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; - INFOPLIST_KEY_UILaunchScreen_Generation = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -427,9 +432,8 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = BrandAccent; CODE_SIGN_IDENTITY = "iPhone Developer"; CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; - INFOPLIST_KEY_UILaunchScreen_Generation = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", diff --git a/Demo/ConvertSwiftSDKDemo/ConvertSwiftSDKDemoApp.swift b/Demo/ConvertSwiftSDKDemo/ConvertSwiftSDKDemoApp.swift index 760c857..3c0d0c3 100644 --- a/Demo/ConvertSwiftSDKDemo/ConvertSwiftSDKDemoApp.swift +++ b/Demo/ConvertSwiftSDKDemo/ConvertSwiftSDKDemoApp.swift @@ -43,6 +43,15 @@ struct ConvertSwiftSDKDemoApp: App { await viewModel.startEventInspector() await viewModel.start() } + // Experiment-preview deep links (qs-02/qs-03 host-app wiring, `Model/ + // DemoViewModel+Preview.swift`). The demo registers the `convertdemo` URL + // scheme (see Info.plist / README "Testing experiment-preview deep links"); + // SwiftUI routes any open-URL event here regardless of which tab is active. + // `applyPreviewLink` is fully inert on a missing/malformed `convert_preview` + // query item, so a stray or malformed URL never corrupts app state. + .onOpenURL { url in + Task { await viewModel.applyPreviewLink(url) } + } // Tear the Event Inspector subscription down when the app moves to // the background — the correct teardown point for the demo. The // `.task` above does NOT re-fire on return to foreground, and this diff --git a/Demo/ConvertSwiftSDKDemo/Info.plist b/Demo/ConvertSwiftSDKDemo/Info.plist new file mode 100644 index 0000000..b47dd9e --- /dev/null +++ b/Demo/ConvertSwiftSDKDemo/Info.plist @@ -0,0 +1,50 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + UILaunchScreen + + + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + com.convert.ConvertSwiftSDKDemo.preview + CFBundleURLSchemes + + convertdemo + + + + + diff --git a/Demo/ConvertSwiftSDKDemo/Model/DemoViewModel+Preview.swift b/Demo/ConvertSwiftSDKDemo/Model/DemoViewModel+Preview.swift new file mode 100644 index 0000000..63d1fb9 --- /dev/null +++ b/Demo/ConvertSwiftSDKDemo/Model/DemoViewModel+Preview.swift @@ -0,0 +1,70 @@ +import ConvertSwiftSDK +import Foundation +import os + +// MARK: - Experiment preview deep links (qs-02 / qs-03 host-app wiring) + +// This extension is the host-app-owned deep-link wiring the qs-02/qs-03 quick-spec explicitly +// scopes OUT of the SDK ("No link registration inside the SDK — host-app concern"). It receives +// whatever URL SwiftUI's `.onOpenURL` (wired in `ConvertSwiftSDKDemoApp.swift`) hands it, and is +// the ONLY place in the demo that calls the pure `PreviewParam.parse` helper and +// `ConvertContext.setPreview(experienceId:variationId:)`. See the README "Testing +// experiment-preview deep links" section for the end-to-end `simctl openurl` test. +// +// Logging: the demo has no existing print/os_log convention (verified — no other file in +// `Demo/ConvertSwiftSDKDemo` logs anything), so this introduces `os.Logger` (unified logging, +// iOS 14+, safe on the iOS 15 floor) scoped to its own subsystem/category so the applied/ignored +// outcome is independently verifiable via `xcrun simctl spawn booted log stream` without needing +// Xcode attached to the process. +extension DemoViewModel { + + /// The unified-logging handle for preview-link diagnostics. + /// + /// A dedicated subsystem (the demo's bundle id) + category (`"preview-link"`) so a log + /// stream can filter to exactly these lines; see the README for the exact `log stream` + /// predicate. + private static let previewLogger = Logger(subsystem: "com.convert.ConvertSwiftSDKDemo", category: "preview-link") + + /// Applies an incoming experiment-preview deep link. + /// + /// Wired from `ConvertSwiftSDKDemoApp.swift`'s `.onOpenURL`. Extracts the `convert_preview` + /// query item, parses it with the SDK's pure ``PreviewParam/parse(_:)`` helper, and — on a + /// successful parse — forces that variation via + /// ``ConvertContext/setPreview(experienceId:variationId:)`` on the sticky ``context``, then + /// re-runs every experience so the forced result appears on the Experiences screen exactly + /// like any other run. + /// + /// Fully inert on a missing or malformed `convert_preview` value: logs the reason and returns + /// without touching ``context``, ``resultCards``, or any other published state — a malformed + /// or absent link can never corrupt the demo's decisioning state. + /// + /// `@MainActor` (inherited): `URLComponents` construction and ``PreviewParam/parse(_:)`` are + /// synchronous; the two SDK calls (`setPreview`, ``runExperiences()``) `await` without + /// blocking the main actor. + func applyPreviewLink(_ url: URL) async { + guard + let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let rawValue = components.queryItems?.first(where: { $0.name == "convert_preview" })?.value + else { + Self.previewLogger.notice("[preview-link] ignored: no convert_preview query item") + return + } + + guard let parsed = PreviewParam.parse(rawValue) else { + Self.previewLogger.notice( + "[preview-link] ignored: malformed convert_preview value \(rawValue, privacy: .public)" + ) + return + } + + await context.setPreview(experienceId: parsed.experienceId, variationId: parsed.variationId) + // Built as a plain `String` first (rather than one long `Logger.notice` string + // interpolation) because `OSLogMessage` interpolations cannot be split across a `+` + // concatenation — the whole message must be a single literal. Wrapping the finished + // string in one `\(…, privacy: .public)` interpolation keeps the log line under the + // line-length limit without losing the per-value privacy annotation semantics. + let appliedMessage = "[preview-link] applied exp=\(parsed.experienceId) var=\(parsed.variationId)" + Self.previewLogger.notice("\(appliedMessage, privacy: .public)") + await runExperiences() + } +} diff --git a/Demo/ConvertSwiftSDKDemo/README.md b/Demo/ConvertSwiftSDKDemo/README.md new file mode 100644 index 0000000..d6e31e7 --- /dev/null +++ b/Demo/ConvertSwiftSDKDemo/README.md @@ -0,0 +1,72 @@ +# ConvertSwiftSDKDemo + +A SwiftUI demo app for the Convert FullStack iOS SDK (Epic 7). It runs OFFLINE by default, +bucketing against the bundled `demo-config.json` (a real FS-Test-Proj staging config with +audience/location gates cleared so the run screens bucket deterministically with no network). + +## Testing experiment-preview deep links + +The demo registers the custom URL scheme `convertdemo://` (see `Info.plist`'s +`CFBundleURLTypes`) so it can receive experiment-preview deep links exactly like a host app +would wire the SDK's qs-02/qs-03 preview surface (`PreviewParam.parse` + +`ConvertContext.setPreview(experienceId:variationId:)`). Link registration and this deep-link +route are a **host-app concern** — the SDK itself does not register any link (explicit +non-goal) — so this wiring lives entirely in the demo: + +- `ConvertSwiftSDKDemoApp.swift` — `.onOpenURL { url in Task { await viewModel.applyPreviewLink(url) } }` +- `Model/DemoViewModel+Preview.swift` — `applyPreviewLink(_:)` parses the link and calls into the SDK + +### Canonical link format + +``` +convertdemo://preview?convert_preview={experienceId}.{variationId} +``` + +`convert_preview` is the same dot-separated `{experienceId}.{variationId}` param the SDK's +`PreviewParam.parse` helper expects — two non-empty, all-digit components separated by a +single `.`. Any other shape (missing param, non-numeric ids, wrong separator) is ignored: the +demo logs the reason and leaves the current context/results untouched. + +### Concrete example (bundled demo-config IDs) + +The bundled `demo-config.json` carries experience `test-experience-ab-fullstack-4` +(id `100349071`) with two variations: `original` (id `1003180877`) and `variation-1` +(id `1003180878`). To force the second variation: + +``` +xcrun simctl openurl booted "convertdemo://preview?convert_preview=100349071.1003180878" +``` + +**Expected result:** the app forces `test-experience-ab-fullstack-4` to `variation-1` and the +Experiences screen's result cards refresh — the top card reads +`test-experience-ab-fullstack-4` / `Variation variation-1`, regardless of whatever the +bucketing hash would otherwise have picked for the current visitor. + +### Confirming zero-trace via the Event Inspector + +Preview is a full decision bypass with **zero trace**: no bucketing enqueue, no sticky-decision +write, no conversion tracking to the source. Open the demo's Event Inspector (the toolbar +button present on every tab) and check the **Events** segment: applying the preview link above +must NOT add a new `.bucketing` row for `test-experience-ab-fullstack-4`. (The Inspector's +**Logs** segment is a placeholder — Story 7.2b, not yet wired to a live log stream — so use +`log stream`, below, for the preview-link diagnostic lines themselves.) + +To see the demo's own `[preview-link] applied …` / `[preview-link] ignored: …` diagnostic line +(emitted via unified logging, not the Event Inspector), stream the simulator log in a separate +terminal before running the `simctl openurl` command: + +``` +xcrun simctl spawn booted log stream --predicate 'subsystem == "com.convert.ConvertSwiftSDKDemo" AND category == "preview-link"' +``` + +### Offline vs. live + +- **In-config experiences preview fully offline.** Any experience already present in + `demo-config.json` (like `test-experience-ab-fullstack-4` above) previews with no network, + since the SDK already holds its `ConfigExperience` and can force the named variation directly. +- **A draft / not-yet-loaded experience needs a live serving host.** If the previewed + `experienceId` is absent from the currently-held config snapshot, the SDK falls back to the + `?exp=`-scoped `ConfigFetchService.fetchExperienceConfig(experienceId:)` fetch, which requires + network access to a real Convert serving host. Point the demo at a live environment via + `ConvertConfiguration`'s `apiConfigEndpoint` (rather than the bundled offline `demo-config.json` + path) before previewing an experience that isn't already in the bundled config. diff --git a/Demo/ConvertSwiftSDKDemo/project.yml b/Demo/ConvertSwiftSDKDemo/project.yml index 6feb5c2..112c85b 100644 --- a/Demo/ConvertSwiftSDKDemo/project.yml +++ b/Demo/ConvertSwiftSDKDemo/project.yml @@ -23,9 +23,6 @@ targets: settings: base: PRODUCT_BUNDLE_IDENTIFIER: com.convert.ConvertSwiftSDKDemo - GENERATE_INFOPLIST_FILE: YES - INFOPLIST_KEY_UILaunchScreen_Generation: YES - INFOPLIST_KEY_UIApplicationSceneManifest_Generation: YES MARKETING_VERSION: "1.0" CURRENT_PROJECT_VERSION: 1 SWIFT_VERSION: "6.0" @@ -36,6 +33,23 @@ targets: # .tint(ConvertTheme.accent) uses), so there is no separate AccentColor # asset to drift out of sync (AC2 — one mode-resolving accent). ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: BrandAccent + # A real Info.plist (rather than GENERATE_INFOPLIST_FILE) is required from here on: + # experiment-preview deep links (qs-02/qs-03 host-app wiring) register the `convertdemo` + # URL scheme via CFBundleURLTypes, which the INFOPLIST_KEY_* build-setting shorthand cannot + # express. XcodeGen writes this file on every `generate` run, so its properties must mirror + # Info.plist exactly; see that file's own header comment for the `simctl openurl` test command. + info: + path: Info.plist + properties: + UILaunchScreen: {} + UIApplicationSceneManifest: + UIApplicationSupportsMultipleScenes: false + LSRequiresIPhoneOS: true + CFBundleURLTypes: + - CFBundleTypeRole: Editor + CFBundleURLName: com.convert.ConvertSwiftSDKDemo.preview + CFBundleURLSchemes: + - convertdemo dependencies: - package: ConvertSwiftSDK product: ConvertSwiftSDK diff --git a/Sources/ConvertSwiftSDK/ConfigFetchService.swift b/Sources/ConvertSwiftSDK/ConfigFetchService.swift index ab8d3ac..95c772f 100644 --- a/Sources/ConvertSwiftSDK/ConfigFetchService.swift +++ b/Sources/ConvertSwiftSDK/ConfigFetchService.swift @@ -95,11 +95,18 @@ public struct ConfigFetchService: ConfigProviding { } /// Assembles `{apiConfigEndpoint}/config/{sdkKey}` and appends `environment={value}` - /// (when set) and `_conv_low_cache=1` (when `networkCacheLevel == .low`). + /// (when set), `debug_token={value}` (qs-02 IOS-1, when set), and `_conv_low_cache=1` + /// (when `networkCacheLevel == .low` OR a `debugToken` is set). /// /// `apiConfigEndpoint` carries NO trailing slash (References F-029), so the route /// path supplies the leading "/". `URLComponents` joins the query items with "&" /// and percent-encodes them; when there are no items the URL has no "?" segment. + /// + /// A `debugToken` FORCES `_conv_low_cache=1` (a QA debug session must never serve a + /// stale CDN-cached config) regardless of the configured `networkCacheLevel`. The + /// force and the `.low` cache level are combined into ONE `||` condition guarding a + /// SINGLE append, so `_conv_low_cache=1` is emitted exactly once even when both a + /// `debugToken` is set AND `networkCacheLevel == .low` — never twice. /// - Returns: The fully-built config URL. /// - Throws: ``ConvertError/invalidConfiguration(_:)`` if the endpoint string is /// malformed or the components cannot resolve to a URL. @@ -109,11 +116,8 @@ public struct ConfigFetchService: ConfigProviding { ) else { throw ConvertError.invalidConfiguration("Malformed config endpoint URL") } - var items: [URLQueryItem] = [] - if let env = configuration.environment { - items.append(URLQueryItem(name: "environment", value: env)) - } - if configuration.networkCacheLevel == .low { + var items = sharedConfigQueryItems() + if configuration.networkCacheLevel == .low || configuration.debugToken != nil { items.append(URLQueryItem(name: "_conv_low_cache", value: "1")) } if !items.isEmpty { @@ -125,6 +129,52 @@ public struct ConfigFetchService: ConfigProviding { return url } + /// Assembles `{apiConfigEndpoint}/config/{sdkKey}?exp={experienceId}` for an experience + /// preview fetch (qs-02 IOS-4): appends `exp={experienceId}`, FORCES + /// `_conv_low_cache=1` UNCONDITIONALLY (a preview must never risk a stale CDN-cached + /// config, regardless of `networkCacheLevel` — unlike ``buildConfigURL()``, which only + /// forces it when a `debugToken` is set OR `networkCacheLevel == .low`), and reuses the + /// shared `environment` / `debug_token` query-item logic ``buildConfigURL()`` already has. + /// - Parameter experienceId: The experience whose preview config is being requested. + /// - Returns: The fully-built experience-preview config URL. + /// - Throws: ``ConvertError/invalidConfiguration(_:)`` if the endpoint string is + /// malformed or the components cannot resolve to a URL. + public func buildExperienceConfigURL(experienceId: String) throws -> URL { + guard var components = URLComponents( + string: configuration.apiConfigEndpoint + "/config/" + configuration.sdkKey + ) else { + throw ConvertError.invalidConfiguration("Malformed config endpoint URL") + } + var items = sharedConfigQueryItems() + items.append(URLQueryItem(name: "exp", value: experienceId)) + items.append(URLQueryItem(name: "_conv_low_cache", value: "1")) + components.queryItems = items + guard let url = components.url else { + throw ConvertError.invalidConfiguration("Could not build config URL") + } + return url + } + + /// Query items common to both ``buildConfigURL()`` and + /// ``buildExperienceConfigURL(experienceId:)``: `environment` (when set) and + /// `debug_token` (qs-02 IOS-1, when set). Extracted so the qs-02 IOS-4 experience-preview + /// URL builder does not duplicate this logic (SonarQube new-code-duplication discipline). + /// Query-item ORDER is not part of either builder's observable contract (both suites + /// assert via `URLComponents` filtering / an order-agnostic dictionary, never a raw + /// string compare), so extracting this without reordering is a pure, behavior-preserving + /// refactor of ``buildConfigURL()``. + /// - Returns: The shared `environment` / `debug_token` query items, in that order. + private func sharedConfigQueryItems() -> [URLQueryItem] { + var items: [URLQueryItem] = [] + if let env = configuration.environment { + items.append(URLQueryItem(name: "environment", value: env)) + } + if let debugToken = configuration.debugToken { + items.append(URLQueryItem(name: "debug_token", value: debugToken)) + } + return items + } + /// Emits one WARN line tagged to this service and the originating `method`. /// /// Centralizes the level/type and the `toLoggable` redaction so every WARN in this @@ -150,8 +200,15 @@ public struct ConfigFetchService: ConfigProviding { /// silently (nothing to log, nothing to delete). When the read SUCCEEDS but the /// bytes fail to decode (corrupt content), a WARN is logged, the corrupt file is /// deleted (so the next load re-fetches), and `nil` is returned (AC4). - /// - Returns: The decoded config, or `nil` on a miss / corrupt cache. + /// + /// With a `debugToken` configured (qs-02 IOS-1, AC2), the disk cache is skipped + /// entirely — this returns `nil` without touching `fileStore` at all, so a QA debug + /// session never resurrects a stale on-disk config from a prior ordinary session. + /// - Returns: The decoded config, or `nil` on a miss / corrupt cache / debug session. public func loadCachedConfig() async -> ProjectConfig? { + if configuration.debugToken != nil { + return nil + } let data: Data do { data = try await fileStore.read(from: cacheURL) @@ -196,52 +253,87 @@ public struct ConfigFetchService: ConfigProviding { return nil } - // Auth header only when a non-empty secret is configured. The secret value is - // never logged, and `toLoggable` strips any sk_/secret material from error text. - var headers: [String: String] = [:] - if let secret = configuration.sdkKeySecret, !secret.isEmpty { - headers["Authorization"] = "Bearer \(secret)" + guard let fetched = await getAndDecode(url: url, method: "fetchLiveConfig") else { + return nil } - // CAPTURE the raw `data` here — it is what gets written through to the cache. - let data: Data + // Write-through the VERBATIM response bytes (inherited contract #4): the exact + // `data` from get(), NOT a re-encode of `config`. A write failure is non-fatal — + // log a WARN and still return the decoded config. Skipped entirely when a + // `debugToken` is configured (qs-02 IOS-1, AC2): a QA debug fetch is never + // persisted to disk. + if configuration.debugToken == nil { + do { + try await fileStore.write(fetched.data, to: cacheURL) + } catch { + warn( + method: "fetchLiveConfig", + reason: "cache write failed", + detail: String(describing: error) + ) + } + } + + return fetched.config + } + + /// Fetches the experience-preview config (qs-02 IOS-4): builds the `?exp={experienceId}` + /// URL, GETs and decodes it exactly like ``fetchLiveConfig()``, but NEVER reads from or + /// writes to the on-disk config cache — a preview/QA config must never persist as the + /// ordinary cached config for subsequent launches. Achieved structurally: this method + /// simply never calls the cache-write step, rather than widening `fetchLiveConfig()`'s + /// own debugToken-gated write condition (which would risk changing `fetchLiveConfig()`'s + /// own observable behavior). + /// - Parameter experienceId: The experience whose preview config is being requested. + /// - Returns: The decoded config on success, or `nil` on URL-build / network / decode + /// failure. + public func fetchExperienceConfig(experienceId: String) async -> ProjectConfig? { + let url: URL do { - (data, _) = try await httpClient.get(url: url, headers: headers) + url = try buildExperienceConfigURL(experienceId: experienceId) } catch { warn( - method: "fetchLiveConfig", - reason: "config fetch failed", + method: "fetchExperienceConfig", + reason: "could not build config URL", detail: String(describing: error) ) return nil } + return await getAndDecode(url: url, method: "fetchExperienceConfig")?.config + } + + /// Shared GET + decode step for ``fetchLiveConfig()`` and + /// ``fetchExperienceConfig(experienceId:)``: sends the auth-headered GET, then decodes the + /// SAME raw bytes (single decoder, NO keyDecodingStrategy — AR13). The only step that + /// differs between the two public methods is the subsequent disk-cache write, which stays + /// in ``fetchLiveConfig()`` — this helper never touches `fileStore`. + /// - Parameters: + /// - url: The pre-built request URL. + /// - method: The originating public method name, for the WARN `{method}` field. + /// - Returns: The decoded config AND the raw response bytes (the latter needed by + /// `fetchLiveConfig()` for its verbatim write-through), or `nil` on any failure stage. + private func getAndDecode(url: URL, method: String) async -> (config: ProjectConfig, data: Data)? { + // Auth header only when a non-empty secret is configured. The secret value is + // never logged, and `toLoggable` strips any sk_/secret material from error text. + var headers: [String: String] = [:] + if let secret = configuration.sdkKeySecret, !secret.isEmpty { + headers["Authorization"] = "Bearer \(secret)" + } - // Decode the SAME raw bytes (single decoder, NO keyDecodingStrategy — AR13). - let config: ProjectConfig + let data: Data do { - config = try JSONDecoder().decode(ProjectConfig.self, from: data) + (data, _) = try await httpClient.get(url: url, headers: headers) } catch { - warn( - method: "fetchLiveConfig", - reason: "config decode failed", - detail: String(describing: error) - ) + warn(method: method, reason: "config fetch failed", detail: String(describing: error)) return nil } - // Write-through the VERBATIM response bytes (inherited contract #4): the exact - // `data` from get(), NOT a re-encode of `config`. A write failure is non-fatal — - // log a WARN and still return the decoded config. do { - try await fileStore.write(data, to: cacheURL) + let config = try JSONDecoder().decode(ProjectConfig.self, from: data) + return (config, data) } catch { - warn( - method: "fetchLiveConfig", - reason: "cache write failed", - detail: String(describing: error) - ) + warn(method: method, reason: "config decode failed", detail: String(describing: error)) + return nil } - - return config } } diff --git a/Sources/ConvertSwiftSDK/ConvertContext.swift b/Sources/ConvertSwiftSDK/ConvertContext.swift index 4adc118..5e6fabc 100644 --- a/Sources/ConvertSwiftSDK/ConvertContext.swift +++ b/Sources/ConvertSwiftSDK/ConvertContext.swift @@ -10,6 +10,7 @@ // named-rule suppression keeps every other rule — and the 400-line gate on every OTHER file — enforced. // swiftlint:disable file_length +import ConvertSwiftSDKCore import Foundation /// A visitor-scoped handle for running experiences/features and tracking conversions. @@ -111,6 +112,15 @@ public final class ConvertContext: Sendable { /// a `let` keeps this class an all-`let` `Sendable final class` with no suppression. private let segmentsManager: SegmentsManager + /// The per-context experience-preview state (qs-02 IOS-5): a FRESH ``PreviewState`` actor built + /// by ``ConvertSwiftSDK/createContext(visitorId:attributes:locationProperties:)`` for THIS + /// context alone (AC7 isolation — never shared across contexts). Holds the memoized `?exp=` + /// preview-fetch cache (IOS-4) and, once ``setPreview(experienceId:variationId:)`` resolves + /// successfully, the forced ``Variation`` that ``runExperience(_:enableTracking:)`` / + /// ``runExperiences(enableTracking:)`` short-circuit to. An `actor` is `Sendable`, so this `let` + /// keeps the class an all-`let` `Sendable final class` with no suppression. + private let previewState: PreviewState + /// The visitor attributes as a loosely-typed `[String: Any]` map, reconstructed on each access /// from the internal ``ConvertValue`` storage via ``ConvertValue/anyValue`` — so a value supplied /// as `["age": 30]` reads back as `attributes["age"] as? Int == 30`. A COMPUTED property (no stored @@ -154,6 +164,8 @@ public final class ConvertContext: Sendable { /// ``SystemEvent/conversion`` on (Story 4.2 / AC9), so `sdk.on(.conversion)` subscribers fire. /// - logger: The SDK's ``Logger`` ``trackConversion(_:goalData:)`` emits its drop-path WARNs to /// (Story 4.2 / AOD-6); default ``NoopLogger`` in production. + /// - previewState: This context's OWN, freshly-built ``PreviewState`` (qs-02 IOS-5), never + /// shared with any other context (AC7 isolation). internal init( sdk: ConvertSwiftSDK, visitorId: String, @@ -164,7 +176,8 @@ public final class ConvertContext: Sendable { featureManager: FeatureManager, eventSink: any EventSink, eventBus: EventBus, - logger: any Logger + logger: any Logger, + previewState: PreviewState ) { self.sdk = sdk self.visitorId = visitorId @@ -176,11 +189,59 @@ public final class ConvertContext: Sendable { self.eventSink = eventSink self.eventBus = eventBus self.logger = logger + self.previewState = previewState // Built over the injected canonical store (not a separate parameter — callers do not pass it), so // every context from the same SDK records segments into the ONE store the decisioning path reads. self.segmentsManager = SegmentsManager(decisionStore: decisionStore, logger: logger) } + /// Sets an experiment-preview target on this context: `runExperience(_:enableTracking:)` / + /// `runExperiences(enableTracking:)` will force the given `variationId` for the experience + /// identified by `experienceId`, bypassing bucketing, rule matching, and stored decisions + /// entirely (qs-02 Experiment Preview, contract §2 "Decision" / §3 "Precedence"). + /// + /// ```swift + /// // given a ready `context` and a parsed deep-link pair + /// await context.setPreview(experienceId: "9001", variationId: "5002") + /// let variation = await context.runExperience("pricing-test") // forced, if the key matches + /// ``` + /// + /// Resolves `experienceId` EAGERLY (this call suspends until resolution completes, so a + /// subsequent `runExperience` sees the outcome immediately): first checks the CURRENT config + /// snapshot's ``ProjectConfig/rawExperiences`` for an entry whose numeric `id` equals + /// `experienceId` (the join key `setPreview` receives is the numeric experience id; the + /// experience is later matched to a `runExperience(_:)` CALL by its string `key`, carried on + /// the resolved ``Variation/experienceKey``). When absent from the current snapshot, falls back + /// to the per-context ``PreviewState/resolveConfig(experienceId:)`` `?exp=` fetch (memoized, + /// 60s TTL, IOS-4) and searches ITS `rawExperiences` the same way. + /// + /// **Inert on bad input:** when `experienceId` cannot be resolved (absent from both the local + /// snapshot AND the fetch) OR ``PreviewDecision/forcedVariation(for:variationId:)`` cannot match + /// `variationId` within the resolved experience's variations, this WARNs and CLEARS any prior + /// forced target via ``PreviewState/clearForcedVariation()`` (JS parity — the JS reference nulls + /// `_preview` on every failure path) — the context (and any experience it is later asked to run, + /// including one previously targeted by an EARLIER successful `setPreview` call) behaves fully + /// normally afterward. The WARN `message` is ONLY the descriptive tail — the adapter composes the + /// `[WARN] ConvertContext.setPreview: …` prefix from `type`/`method` (UX-DR19). + /// - Parameters: + /// - experienceId: The numeric experience id to force (from `PreviewParam.parse`'s + /// `experienceId`, or supplied directly). + /// - variationId: The numeric variation id to force within that experience. + public func setPreview(experienceId: String, variationId: String) async { + let localConfig = await sdk.configStore.getSnapshot() + guard let forced = await resolvePreviewForcedVariation( + experienceId: experienceId, + variationId: variationId, + localConfig: localConfig, + previewState: previewState + ) else { + logPreviewResolutionFailure(logger: logger, experienceId: experienceId, variationId: variationId) + await previewState.clearForcedVariation() + return + } + await previewState.setForcedVariation(forced) + } + /// Runs one experience and returns the bucketed ``Variation``, or `nil` when none applies. /// /// ```swift @@ -215,17 +276,30 @@ public final class ConvertContext: Sendable { // manager (AC10, no throw). return nil } + // qs-02 IOS-5 / contract §3 (Precedence): a resolved preview target for THIS experience + // beats stored decisions and normal bucketing — return it BEFORE touching + // `experienceManager.selectVariation` at all, so sticky lookup / rule matching / the + // bucketing hash are never consulted for the target. A target set for a DIFFERENT + // experience key (or no target at all) falls through to the normal path unaffected. + if let forced = await previewState.forcedVariation, forced.experienceKey == key { + return forced + } + // qs-02 IOS-6 / AC6 (zero-trace): a preview target on THIS context — the forced key above, or + // ANY OTHER key — suppresses tracking/persistence at the SOURCE for every sibling experience + // (contract §2). Gated on the PER-CONTEXT `previewState`, never `isTrackingEnabled()` (global). + let previewActive = await previewState.isPreviewActive // AC11: overlay the visitor's persisted segments onto the explicit attribute map so an audience // rule can match on a `setDefaultSegments` value (e.g. country). Read under the SAME store key the // manager rebuilds internally; explicit createContext attributes still win on collision. let segments = await decisionStore.currentSegments(forVisitorKey: storeKey(for: config)) let attributes = mergedAttributes(stringAttributes(), with: segments) - // Thread the COMBINED gate (FR6 global tracking flag AND the per-call `enableTracking`) into - // the manager: the variation is still selected/persisted/fired, but `BucketingManager` skips the - // bucketing enqueue when EITHER flag is false — so a globally-disabled SDK enqueues nothing at the - // sink even though decisioning is unchanged (Story 5.4 / AC1, AC3; Story 5.6 extends to the - // runtime-mutable flag). The public `enableTracking` parameter and its default are unchanged; - // only the value threaded down is combined. + // Thread the COMBINED gate (FR6 global tracking, per-call `enableTracking`, and IOS-6's + // `!previewActive`) into the manager: the variation is still selected/persisted, but + // `BucketingManager` skips the enqueue when ANY flag is false (Story 5.4/5.6; qs-02 IOS-6 + // extends to preview). `persistDecision: !previewActive` also suppresses the sticky WRITE for + // a sibling under preview (AC6); the sticky READ above is unaffected. `emitBucketing: + // !previewActive` (qs-02 Fix 1, JS parity) suppresses the `.bucketing` EventBus fire for + // every experience while preview is active, matching JS's `if (!this._preview)` guard. return await experienceManager.selectVariation( forKey: key, in: config, @@ -234,7 +308,9 @@ public final class ConvertContext: Sendable { projectId: config.project?.id ?? "", attributes: attributes, locationProperties: stringLocationProperties(), - enableTracking: await sdk.isTrackingEnabled() && enableTracking + enableTracking: await sdk.isTrackingEnabled() && enableTracking && !previewActive, + persistDecision: !previewActive, + emitBucketing: !previewActive ) } @@ -247,29 +323,14 @@ public final class ConvertContext: Sendable { /// the immutable ``attributesStorage`` — it allocates a fresh dictionary per call but is invoked /// once per `runExperience`, so there is no retained mutable state and the class stays `Sendable`. private func stringAttributes() -> [String: String] { - Self.stringified(attributesStorage) + stringified(attributesStorage) } /// The location properties as the `[String: String]` map the LOCATION gate compares against — the /// same stringify rule as ``stringAttributes()`` (shared via ``stringified(_:)`` so the two /// gate-input builders never diverge). Empty when no location properties were supplied to the context. private func stringLocationProperties() -> [String: String] { - Self.stringified(locationPropertiesStorage) - } - - /// Stringifies a coerced ``ConvertValue`` map to the `[String: String]` form the rule/segment engine - /// compares against (a string stays itself; int/double/bool render via their `String(_:)` initialisers). - /// Shared by ``stringAttributes()`` (audience gate) and ``stringLocationProperties()`` (location gate) - /// so neither re-derives the switch (DRY — keeps the diff under the SonarQube CPD gate). - private static func stringified(_ values: [String: ConvertValue]) -> [String: String] { - values.mapValues { value in - switch value { - case .string(let string): return string - case .int(let int): return String(int) - case .double(let double): return String(double) - case .bool(let bool): return String(bool) - } - } + stringified(locationPropertiesStorage) } /// The sticky store key `"--"` for the given config snapshot. @@ -282,22 +343,6 @@ public final class ConvertContext: Sendable { "\(config.accountId ?? "")-\(config.project?.id ?? "")-\(visitorId)" } - /// Overlays the visitor's non-nil string segment fields onto the explicit attribute map so audience - /// rules can match on `country`/`visitorType`/etc. Explicit attributes WIN on key collision (the - /// caller's createContext attribute is more specific than a stored segment). `customSegments` is an - /// array, not a scalar attribute, so it is NOT overlaid. [Source: AC11] - private func mergedAttributes(_ attributes: [String: String], with segments: Segments) -> [String: String] { - var merged = attributes - let segmentPairs: [(String, String?)] = [ - ("country", segments.country), ("browser", segments.browser), ("devices", segments.devices), - ("source", segments.source), ("campaign", segments.campaign), ("visitorType", segments.visitorType) - ] - for (key, value) in segmentPairs where merged[key] == nil { - if let value { merged[key] = value } - } - return merged - } - /// Runs every configured experience for this visitor and returns the bucketed ``Variation`` for /// each eligible one, in config order. /// @@ -334,20 +379,46 @@ public final class ConvertContext: Sendable { // diverge) — each experience's audience gate sees the visitor's persisted segments. let segments = await decisionStore.currentSegments(forVisitorKey: storeKey(for: config)) let attributes = mergedAttributes(stringAttributes(), with: segments) - // Thread the COMBINED gate (global runtime tracking flag AND per-call `enableTracking`) into the - // bulk path, exactly as the single-experience path does (run-all mirrors run-single, not diverge): - // each per-experience bucketing enqueue is suppressed when EITHER flag is false, while every - // variation is still selected/persisted/fired (Story 5.4 / AC1, AC3; Story 5.6 extends to - // the runtime-mutable flag). - return await experienceManager.selectVariations( - in: config, + // qs-02 IOS-5 / contract §2 ("other experiences still evaluate and decide normally") + §3 + // (Precedence): a resolved preview target is EXCLUDED from the bulk config passed to + // `experienceManager.selectVariations` (a shallow copy with `rawExperiences` filtered by + // `key`, the ONLY field that method reads) so bucketing/rule-matching/sticky-lookup never + // runs for the target — mirroring the single-experience short-circuit above — while every + // sibling experience still decides through the completely normal bulk path. The forced + // `Variation` is appended afterward so the target's result is still present in the returned + // array (AC7 isolation test / "forces only the target" test read it back via + // `experienceKey`, order-independent). + let forced = await previewState.forcedVariation + var effectiveConfig = config + if let forced { + effectiveConfig.rawExperiences = config.rawExperiences?.filter { $0.key != forced.experienceKey } + } + // qs-02 IOS-6 / AC6 (zero-trace): the same per-context gate `runExperience` applies (see its + // comment) — a preview target suppresses tracking/persistence for every SIBLING experience, + // not just the (already excluded-from-this-call) forced target. Gated on `previewState`, + // never the global `isTrackingEnabled()`. + let previewActive = forced != nil + // Thread the COMBINED gate (global tracking, per-call `enableTracking`, IOS-6's + // `!previewActive`) into the bulk path, mirroring the single-experience path (Story 5.4/5.6; + // qs-02 IOS-6 extends to preview). `persistDecision: !previewActive` suppresses the sticky + // WRITE for each sibling under preview (AC6). `emitBucketing: !previewActive` (qs-02 Fix 1, + // JS parity) suppresses the `.bucketing` EventBus fire for every sibling while preview is + // active, matching JS's `if (!this._preview)` guard. + var results = await experienceManager.selectVariations( + in: effectiveConfig, visitorId: visitorId, accountId: config.accountId ?? "", projectId: config.project?.id ?? "", attributes: attributes, locationProperties: stringLocationProperties(), - enableTracking: await sdk.isTrackingEnabled() && enableTracking + enableTracking: await sdk.isTrackingEnabled() && enableTracking && !previewActive, + persistDecision: !previewActive, + emitBucketing: !previewActive ) + if let forced { + results.append(forced) + } + return results } /// Resolves one feature flag and returns its ``Feature`` — non-optional by contract, so @@ -382,6 +453,14 @@ public final class ConvertContext: Sendable { /// `network.tracking` is off, the PRODUCTION ``EventQueue`` drops that entry at its own static gate /// (`trackingEnabled`), so no event reaches the network — the suppression happens one seam later than /// on the experience/conversion paths, not at this caller. + /// + /// qs-02 IOS-fix2 / contract §2 (zero-trace): a preview target on THIS context (any key, not just a + /// carrying experience's) still suppresses the bucketing enqueue, the sticky WRITE, and (qs-02 Fix + /// 1, JS parity) the `.bucketing` `EventBus` fire for whichever experience carries this feature — + /// gated on the PER-CONTEXT `previewState`, never the global `network.tracking` flag (deliberately + /// NOT combined with it, mirroring the scope asymmetry above: the feature path stays uncoupled from + /// `isTrackingEnabled()`). The feature itself still RESOLVES normally (coherent rendering) — only + /// tracking/persistence/observer-notification at the source is suppressed. /// - Parameter key: The feature `key` to look up and resolve. /// - Returns: The resolved ``Feature`` — `.enabled` with typed variables, or `.disabled` on a /// missing snapshot / miss. @@ -391,11 +470,20 @@ public final class ConvertContext: Sendable { // manager (AOD-6, no throw). return Feature.disabled(key: key) } + // qs-02 IOS-fix3 (torn-read close): hoist ONE actor read into a local, mirroring + // `runExperience(_:enableTracking:)` — two independent `await previewState.isPreviewActive` + // reads are two separate suspension points, and a concurrent `setPreview` call landing between + // them could torn-gate `enableTracking`/`persistDecision` from two different preview states + // (a zero-trace leak risk). A single read closes the window. + let previewActive = await previewState.isPreviewActive // AC11 (JS parity, bd-0ca): overlay the visitor's persisted segments onto the explicit attribute map // so the carrying experience's audience gate can match on a `setDefaultSegments` value, exactly as // runExperience does — JS context.ts calls getVisitorProperties identically on the feature path. let segments = await decisionStore.currentSegments(forVisitorKey: storeKey(for: config)) let attributes = mergedAttributes(stringAttributes(), with: segments) + // qs-02 IOS-fix2 (AC6 zero-trace): gated on the PER-CONTEXT `previewState`, never + // `isTrackingEnabled()` (global) — see the doc comment above for why this is NOT combined + // with the global flag on this path. return await featureManager.evaluateFeature( key: key, in: config, @@ -403,7 +491,10 @@ public final class ConvertContext: Sendable { accountId: config.accountId ?? "", projectId: config.project?.id ?? "", attributes: attributes, - locationProperties: stringLocationProperties() + locationProperties: stringLocationProperties(), + enableTracking: !previewActive, + persistDecision: !previewActive, + emitBucketing: !previewActive ) } @@ -426,24 +517,37 @@ public final class ConvertContext: Sendable { /// /// As with ``runFeature(_:)``, this method takes NO `enableTracking` parameter (Android parity, F-171): /// the feature path is not per-call tracking-gated. + /// + /// qs-02 IOS-fix2 / contract §2 (zero-trace): same per-context `previewState` gate as + /// ``runFeature(_:)`` applies to every feature evaluated here — see its doc comment for the scope + /// asymmetry rationale (deliberately NOT combined with `isTrackingEnabled()`). /// - Returns: One ``Feature`` per `config.features` entry, in config order; `[]` on a missing /// snapshot. public func runFeatures() async -> [Feature] { guard let config = await sdk.configStore.getSnapshot() else { return [] } + // qs-02 IOS-fix3 (torn-read close): same single-read hoist as `runFeature(_:)` — see its + // comment for why two independent `await previewState.isPreviewActive` reads are a torn-gate + // risk under a concurrent `setPreview` call. + let previewActive = await previewState.isPreviewActive // AC11 (JS parity, bd-0ca): same segment overlay as the single-feature path (run-all mirrors // run-single, not diverge) — each feature's carrying-experience audience gate sees the visitor's // persisted segments. let segments = await decisionStore.currentSegments(forVisitorKey: storeKey(for: config)) let attributes = mergedAttributes(stringAttributes(), with: segments) + // qs-02 IOS-fix2 (AC6 zero-trace): gated on the PER-CONTEXT `previewState`, never the global + // `isTrackingEnabled()` — mirrors `runFeature(_:)`. return await featureManager.evaluateAllFeatures( in: config, visitorId: visitorId, accountId: config.accountId ?? "", projectId: config.project?.id ?? "", attributes: attributes, - locationProperties: stringLocationProperties() + locationProperties: stringLocationProperties(), + enableTracking: !previewActive, + persistDecision: !previewActive, + emitBucketing: !previewActive ) } @@ -521,6 +625,10 @@ public final class ConvertContext: Sendable { ) return } + // qs-02 IOS-6 / AC6 (zero-trace): a preview target on THIS context suppresses conversion + // tracking ENTIRELY — the dedup persist below, both enqueues, and the `.conversion` bus fire. + // Gated on the PER-CONTEXT `previewState`, never the global `isTrackingEnabled()`. + guard !(await previewState.isPreviewActive) else { return } // Sticky store key "--" (the runExperience key shape); goalId // resolved ONCE so the enqueued event and the `.conversion` bus payload share it. let storeKey = "\(config.accountId ?? "")-\(config.project?.id ?? "")-\(visitorId)" @@ -594,6 +702,10 @@ public final class ConvertContext: Sendable { /// `devices`, `source`, `campaign`, `visitorType`); unrecognised keys are ignored with a WARN. /// [Source: AC1, AC12] public func setDefaultSegments(_ segments: [String: String]) async { + // qs-02 IOS-6 / AC6 (zero-trace): a preview target on THIS context suppresses the segments + // update ENTIRELY — no delegation to the shared `SegmentsManager`, no persist, no `.segments` + // bus fire. Gated on the PER-CONTEXT `previewState`, never the global `isTrackingEnabled()`. + guard !(await previewState.isPreviewActive) else { return } guard let config = await sdk.configStore.getSnapshot() else { logger.log( level: .warn, @@ -624,6 +736,11 @@ public final class ConvertContext: Sendable { /// - Parameter segmentIds: The custom segment identifiers to append to the visitor's `customSegments`. /// [Source: AC2, AC12] public func setCustomSegments(_ segmentIds: [String]) async { + // qs-02 IOS-6 / AC6 (zero-trace): a preview target on THIS context suppresses the custom + // segments update ENTIRELY — no delegation to the shared `SegmentsManager`, no persist, no + // `.segments` bus fire. Gated on the PER-CONTEXT `previewState`, never the global + // `isTrackingEnabled()`. + guard !(await previewState.isPreviewActive) else { return } guard let config = await sdk.configStore.getSnapshot() else { logger.log( level: .warn, @@ -639,3 +756,105 @@ public final class ConvertContext: Sendable { await eventBus.fire(.segments, payload: .segments(SegmentsPayload(visitorId: visitorId, segments: updated))) } } + +/// Stringifies a coerced ``ConvertValue`` map to the `[String: String]` form the rule/segment engine +/// compares against (a string stays itself; int/double/bool render via their `String(_:)` initialisers). +/// Shared by ``ConvertContext/stringAttributes()`` (audience gate) and +/// ``ConvertContext/stringLocationProperties()`` (location gate) so neither re-derives the switch (DRY — +/// keeps the diff under the SonarQube CPD gate). +/// +/// A file-scope function (not a `ConvertContext` method — it touches no `self` state, only its +/// parameter) so it does not count against the class's `type_body_length` budget (qs-02 IOS-fix3; +/// precedent: ``mergedAttributes(_:with:)`` / +/// ``resolvePreviewForcedVariation(experienceId:variationId:localConfig:previewState:)`` below). +/// `file_length` is already disabled file-wide for this file (see the top-of-file rationale), so +/// this move adds no NEW suppression. +private func stringified(_ values: [String: ConvertValue]) -> [String: String] { + values.mapValues { value in + switch value { + case .string(let string): return string + case .int(let int): return String(int) + case .double(let double): return String(double) + case .bool(let bool): return String(bool) + } + } +} + +/// Overlays the visitor's non-nil string segment fields onto the explicit attribute map so audience +/// rules can match on `country`/`visitorType`/etc. Explicit attributes WIN on key collision (the +/// caller's createContext attribute is more specific than a stored segment). `customSegments` is an +/// array, not a scalar attribute, so it is NOT overlaid. [Source: AC11] +/// +/// A file-scope function (not a `ConvertContext` method — it touches no `self` state, only its two +/// parameters) so it does not count against the class's `type_body_length` budget (qs-02 IOS-6; +/// precedent: ``resolvePreviewForcedVariation(experienceId:variationId:localConfig:previewState:)`` +/// below). `file_length` is already disabled file-wide for this file (see the top-of-file +/// rationale), so this move adds no NEW suppression. +private func mergedAttributes(_ attributes: [String: String], with segments: Segments) -> [String: String] { + var merged = attributes + let segmentPairs: [(String, String?)] = [ + ("country", segments.country), ("browser", segments.browser), ("devices", segments.devices), + ("source", segments.source), ("campaign", segments.campaign), ("visitorType", segments.visitorType) + ] + for (key, value) in segmentPairs where merged[key] == nil { + if let value { merged[key] = value } + } + return merged +} + +/// Resolves the ``Variation`` forced by an experiment-preview target (qs-02 IOS-5, +/// ``ConvertContext/setPreview(experienceId:variationId:)``): checks `localConfig`'s +/// ``ProjectConfig/rawExperiences`` for an entry whose numeric `id` equals `experienceId` FIRST; +/// when absent, falls back to `previewState`'s memoized `?exp=` fetch +/// (``PreviewState/resolveConfig(experienceId:)``) and searches ITS `rawExperiences` the same +/// way. Once the experience is found (from either source), matches `variationId` via +/// ``PreviewDecision/forcedVariation(for:variationId:)``. +/// +/// A file-scope function (not a `ConvertContext` method) so it does not count against the +/// class's `type_body_length` budget — `file_length` is already disabled file-wide for this file +/// (see the top-of-file rationale), so this helper adds no NEW suppression. +/// - Parameters: +/// - experienceId: The numeric experience id to resolve (the join key ``ConvertContext`` +/// receives from `setPreview`; the experience's STRING `key`, carried on the returned +/// ``Variation/experienceKey``, is what a later `runExperience(_:)` call is matched against). +/// - variationId: The numeric variation id to force within the resolved experience. +/// - localConfig: The context's current config snapshot, checked first. +/// - previewState: The context's own ``PreviewState``, whose memoized fetch is the fallback. +/// - Returns: The forced ``Variation``, or `nil` when the experience or variation could not be +/// resolved (inert-on-bad-input — the caller logs the WARN). +private func resolvePreviewForcedVariation( + experienceId: String, + variationId: String, + localConfig: ProjectConfig?, + previewState: PreviewState +) async -> Variation? { + let experience: Components.Schemas.ConfigExperience? + if let localMatch = localConfig?.rawExperiences?.first(where: { $0.id == experienceId }) { + experience = localMatch + } else { + let fetchedConfig = await previewState.resolveConfig(experienceId: experienceId) + experience = fetchedConfig?.rawExperiences?.first { $0.id == experienceId } + } + guard let experience else { return nil } + return PreviewDecision.forcedVariation(for: experience, variationId: variationId) +} + +/// Logs the `setPreview` inert-on-bad-input WARN when +/// ``resolvePreviewForcedVariation(experienceId:variationId:localConfig:previewState:)`` returns +/// `nil` — a file-scope helper (see that function's placement rationale) so this stays out of +/// ``ConvertContext``'s `type_body_length` budget. The `message` is ONLY the descriptive tail — +/// the adapter composes the `[WARN] ConvertContext.setPreview: …` prefix from `type`/`method` +/// (UX-DR19). +/// - Parameters: +/// - logger: The context's ``Logger`` to emit the WARN to. +/// - experienceId: The unresolved preview target's experience id, echoed in the message. +/// - variationId: The unresolved preview target's variation id, echoed in the message. +private func logPreviewResolutionFailure(logger: any Logger, experienceId: String, variationId: String) { + logger.log( + level: .warn, + type: "ConvertContext", + method: "setPreview", + message: "preview target experienceId '\(experienceId)' / variationId '\(variationId)' " + + "could not be resolved — falling through to normal decisions." + ) +} diff --git a/Sources/ConvertSwiftSDK/ConvertSwiftSDK.swift b/Sources/ConvertSwiftSDK/ConvertSwiftSDK.swift index 87b3f52..4f676eb 100644 --- a/Sources/ConvertSwiftSDK/ConvertSwiftSDK.swift +++ b/Sources/ConvertSwiftSDK/ConvertSwiftSDK.swift @@ -142,6 +142,16 @@ public final class ConvertSwiftSDK: Sendable { /// `configuration.networkTracking` in `init`. private let trackingState: TrackingState + /// The HTTP transport ``createContext`` uses to build each context's OWN per-context + /// ``ConfigFetchService`` for the experience-preview `?exp=` fetch (qs-02 IOS-5). `nil` (the + /// default — production) resolves to a fresh ``URLSessionHTTPClient`` in ``createContext``, + /// mirroring the MAIN config load's real transport (`ConvertSwiftSDK.swift`, the detached + /// load `Task`'s `activeProvider` construction); a test injects a `MockHTTPClient` to stub the + /// preview fetch WITHOUT touching the main `configProvider` seam — the two are separate + /// ``ConfigFetchService`` instances. The ``HTTPClient`` port refines `Sendable`, so this `let` + /// keeps the class an all-`let` `Sendable final class` with no suppression. + private let previewHTTPClient: (any HTTPClient)? + /// Dependency-injecting initializer (the test seam). Stores its dependencies, creates the /// ``ConfigStore`` over the shared ``EventBus``, then launches the detached config-load /// task. Non-throwing and non-blocking — validation and the real config fetch happen in the @@ -176,6 +186,10 @@ public final class ConvertSwiftSDK: Sendable { /// injects a `MockLogger`. /// - decisionStore: The ONE canonical ``DecisionStore`` injected into every context this SDK /// creates. Defaults to a fresh empty store; a test injects its own to assert shared identity. + /// - previewHTTPClient: The HTTP transport ``createContext`` uses for each context's own + /// per-context experience-preview ``ConfigFetchService`` (qs-02 IOS-5). `nil` (the default) + /// selects a fresh production ``URLSessionHTTPClient`` in ``createContext``; a test injects a + /// `MockHTTPClient` to stub the `?exp=` preview fetch independently of `configProvider`. /// /// The body exceeds the 50-line `function_body_length` default by a small margin because it wires /// the SDK's full collaborator graph (config store, the resolved `EventSink`, the shared @@ -195,7 +209,8 @@ public final class ConvertSwiftSDK: Sendable { keyValueStore: any KeyValueStore = UserDefaultsKeyValueStore(), eventSink: (any EventSink)? = nil, logger: any Logger = NoopLogger(), - decisionStore: DecisionStore = DecisionStore(logger: NoopLogger(), fileStore: ApplicationSupportFileStore()) + decisionStore: DecisionStore = DecisionStore(logger: NoopLogger(), fileStore: ApplicationSupportFileStore()), + previewHTTPClient: (any HTTPClient)? = nil ) { self.configuration = configuration self.eventBus = eventBus @@ -204,6 +219,7 @@ public final class ConvertSwiftSDK: Sendable { self.keyValueStore = keyValueStore self.decisionStore = decisionStore self.logger = logger + self.previewHTTPClient = previewHTTPClient // Seed the runtime tracking flag from the init-time config value (Story 5.6 / AC3). // Held as a `let` actor reference so this class stays an all-`let` Sendable final class. self.trackingState = TrackingState(initialValue: configuration.networkTracking) @@ -668,6 +684,21 @@ public final class ConvertSwiftSDK: Sendable { } let coercedAttributes = coerce(attributes, label: "attribute") let coercedLocationProperties = coerce(locationProperties, label: "location property") + // A FRESH per-context ``ConfigFetchService`` (qs-02 IOS-5) — a SECOND, independent + // ``ConfigProviding`` instance from the MAIN config's `activeProvider` built inside the + // detached load `Task` above: same shape (``previewHTTPClient`` or a fresh production + // ``URLSessionHTTPClient``, a fresh ``CoordinatedFileStore()``, this SDK's `configuration` + // and `logger`), but never shared with the main config load or with any OTHER context — + // so a preview fetch never contends with the main config's cache file. A FRESH + // ``PreviewState`` per context (never a shared SDK-level instance) is what guarantees AC7 + // isolation: one context's preview target can never leak into a sibling context. + let previewFetchService = ConfigFetchService( + httpClient: previewHTTPClient ?? URLSessionHTTPClient(sdkVersion: SDKVersion.current), + fileStore: CoordinatedFileStore(), + configuration: configuration, + logger: logger + ) + let previewState = PreviewState(fetchService: previewFetchService) return ConvertContext( sdk: self, visitorId: resolvedId, @@ -678,7 +709,8 @@ public final class ConvertSwiftSDK: Sendable { featureManager: featureManager, eventSink: eventSink, eventBus: eventBus, - logger: logger + logger: logger, + previewState: previewState ) } } diff --git a/Sources/ConvertSwiftSDK/PreviewState.swift b/Sources/ConvertSwiftSDK/PreviewState.swift new file mode 100644 index 0000000..2e75cea --- /dev/null +++ b/Sources/ConvertSwiftSDK/PreviewState.swift @@ -0,0 +1,137 @@ +// PreviewState.swift +// Experience-preview in-memory state (qs-02 IOS-4). +// +// Placement rationale: in the `ConvertSwiftSDK` platform target (NOT `ConvertSwiftSDKCore`) +// because it holds a concrete `ConfigFetchService`, which composes the Foundation-backed +// `CoordinatedFileStore` (a platform-layer type) — mirrors `TrackingState`'s placement +// rationale of keeping platform-composed dependencies out of the pure-logic Core target. +// +// `PreviewState` is deliberately NOT single-purpose to the memo: a later story (qs-02 IOS-5) +// will ALSO use this SAME actor to hold the per-context current `(experienceId, variationId)` +// preview target (mirroring `TrackingState`'s "one actor, held by `let`, on the owning type" +// shape), rather than introducing a second actor. + +import ConvertSwiftSDKCore +import Foundation + +/// Actor-isolated experience-preview state: memoizes +/// `ConfigFetchService.fetchExperienceConfig(experienceId:)` results IN-MEMORY ONLY (never the +/// on-disk config cache), keyed per `experienceId`, with a 60s TTL driven by an injectable +/// ``Clock`` (the same `Clock` port `ConfigRefreshScheduler` already uses). +/// +/// Expired entries are swept on EVERY access — both a memo hit/miss check on `resolveConfig` +/// and the read-only ``memoCount`` introspection recompute live-only on demand — bounding +/// growth without a separate timer. +actor PreviewState { + /// One memoized experience-preview config: the decoded config and the instant it was + /// fetched. A named struct (not a tuple) keeps the entry self-documenting and gives a + /// later story (qs-02 IOS-5, the current preview-target field) room to grow this actor's + /// stored state without reshaping this type. + private struct MemoEntry { + let config: ProjectConfig + let fetchedAt: Date + } + + /// The memoization TTL: an entry is expired once MORE than this many seconds have + /// elapsed since it was fetched (exactly `ttl` elapsed is still considered live). + private static let ttl: TimeInterval = 60 + + /// The fetch service used to resolve an experience's preview config on a memo miss. + private let fetchService: ConfigFetchService + /// Injectable time source (NFR21) — tests substitute a `MockClock` to advance time + /// synthetically without a wall-clock wait. + private let clock: any Clock + /// The memo surface: one entry per `experienceId`, pruned of expired entries on access. + private var memo: [String: MemoEntry] = [:] + + /// The current preview target's resolved forced decision (qs-02 IOS-5), or `nil` when no + /// preview target has been set on the owning context, or the last ``ConvertContext/setPreview`` + /// resolution FAILED (inert-on-bad-input — a failed resolution CLEARS any prior target via + /// ``clearForcedVariation()``, matching the JS reference's `_preview = null` on every failure + /// path, so a stale forced decision from an earlier successful `setPreview` can never survive + /// a subsequent unresolved one). + /// `ConvertContext.runExperience(_:enableTracking:)` / `runExperiences(enableTracking:)` compare + /// this `Variation`'s `experienceKey` against the key being run to decide whether to + /// short-circuit before ``ExperienceManager``. + private(set) var forcedVariation: Variation? + + /// Creates the preview state. + /// - Parameters: + /// - fetchService: The concrete `ConfigFetchService` used to resolve a memo miss. + /// - clock: Time source for TTL math; defaults to ``SystemClock()`` in production. + init(fetchService: ConfigFetchService, clock: any Clock = SystemClock()) { + self.fetchService = fetchService + self.clock = clock + } + + /// The number of currently LIVE (non-expired) memo entries — test-only introspection. + /// Recomputed against the CURRENT clock reading on every access, so it reflects reality + /// even if nothing has triggered a sweep since an entry expired. + var memoCount: Int { + liveEntries(at: clock.now).count + } + + /// Whether a preview target is CURRENTLY set on the owning context (qs-02 IOS-6, AC6 zero-trace + /// gate): `true` once ``setForcedVariation(_:)`` has recorded a resolved target, `false` when no + /// ``ConvertContext/setPreview(experienceId:variationId:)`` call has yet succeeded. Mirrors + /// ``forcedVariation``'s own nil-ness rather than tracking a separate flag, so the two can never + /// drift out of sync. `ConvertContext` reads this to gate the bucketing enqueue, the sticky-decision + /// write, and conversion tracking to the SOURCE — independent of the SDK-shared + /// `ConvertSwiftSDK.isTrackingEnabled()` runtime flag, which is a different (global) axis. + var isPreviewActive: Bool { + forcedVariation != nil + } + + /// Resolves the preview config for `experienceId`, memoizing it in-memory for 60s. + /// + /// Sweeps expired entries (across ALL ids, not just `experienceId`) before consulting + /// the memo, so an access for one id also bounds growth from a different, now-expired + /// id (qs-02 IOS-4). On a memo hit within the TTL, returns the memoized config with no + /// fetch. On a miss (absent or expired), fetches via + /// `ConfigFetchService.fetchExperienceConfig(experienceId:)`; a successful fetch is + /// memoized, a `nil` fetch is NOT memoized (so the next call retries rather than + /// caching a failure). + /// - Parameter experienceId: The experience whose preview config is being resolved. + /// - Returns: The (possibly memoized) config, or `nil` if the underlying fetch failed. + func resolveConfig(experienceId: String) async -> ProjectConfig? { + let now = clock.now + memo = liveEntries(at: now) + + if let entry = memo[experienceId] { + return entry.config + } + + guard let config = await fetchService.fetchExperienceConfig(experienceId: experienceId) else { + return nil + } + memo[experienceId] = MemoEntry(config: config, fetchedAt: clock.now) + return config + } + + /// Records the resolved forced-decision target for the owning context (qs-02 IOS-5). + /// `ConvertContext.setPreview(experienceId:variationId:)` calls this ONLY after successfully + /// resolving `experienceId`/`variationId` into a ``Variation`` via + /// ``PreviewDecision/forcedVariation(for:variationId:)`` — an unresolved `setPreview` call + /// never reaches here, leaving any prior target untouched. + /// - Parameter variation: The forced decision to record. + func setForcedVariation(_ variation: Variation) { + forcedVariation = variation + } + + /// Clears any prior forced-decision target for the owning context (JS parity — the JS + /// reference nulls its `_preview` field on every `setPreview` failure path). Called by + /// ``ConvertContext/setPreview(experienceId:variationId:)`` when + /// ``PreviewDecision/forcedVariation(for:variationId:)`` (or the upstream experience + /// resolution) fails, so a FAILED re-preview call can never leave a STALE forced decision + /// from an earlier successful `setPreview` in place. A no-op when no target was set. + func clearForcedVariation() { + forcedVariation = nil + } + + /// Filters ``memo`` down to entries that are not yet expired as of `now`. + /// - Parameter now: The clock reading to measure elapsed time against. + /// - Returns: The subset of ``memo`` whose age is at most ``ttl``. + private func liveEntries(at now: Date) -> [String: MemoEntry] { + memo.filter { now.timeIntervalSince($0.value.fetchedAt) <= Self.ttl } + } +} diff --git a/Sources/ConvertSwiftSDKCore/Bucketing/AnchoredBucketing.swift b/Sources/ConvertSwiftSDKCore/Bucketing/AnchoredBucketing.swift new file mode 100644 index 0000000..e72184e --- /dev/null +++ b/Sources/ConvertSwiftSDKCore/Bucketing/AnchoredBucketing.swift @@ -0,0 +1,99 @@ +// Sources/ConvertSwiftSDKCore/Bucketing/AnchoredBucketing.swift +// ANCHORED bucketing layout (qs-01, cross-SDK bucketing contract v12) — Phase 2 / GREEN. +// +// Selected per experience by `BucketingManager.bucketVersionGated(...)`: `experience.version > 11` +// routes here; `version <= 11` / missing / non-numeric stays on the EXISTING packed walk in +// `BucketingManager.bucket(...)` (untouched, AC6). Spec of record (do NOT re-derive the algorithm +// here): `2026-06-09-convert-ios-sdk/qs-01-anchored-bucketing-layout.md` — mirrors the JS +// reference exactly (`data-manager.ts:591-610` `_buildVariationAllocations`, +// `bucketing-manager.ts:152-204` `getBucketRanges`/`selectBucketAnchored`): +// +// allocations = experience.variations (config order), entries with no `id` DROPPED (never +// counted toward totalWeight — matches `_buildVariationAllocations`'s `if (!variation?.id) +// return allocations`) → +// { id, allocation: isNaN(ta) || ta absent ? 100.0 : ta, +// active: (status == nil || status == RUNNING) && (ta > 0 || isNaN(ta) || ta absent) } +// totalWeight = sum of allocation over ALL remaining entries (active AND inactive) +// if totalWeight <= 0 → not bucketed +// cumWeight = 0 +// for each entry in order: +// anchor = (cumWeight / totalWeight) * 10000.0 +// width = entry.active ? entry.allocation * 100.0 : 0.0 +// if value >= anchor && value < anchor + width → return entry.id +// cumWeight += entry.allocation +// return nil +// +// All arithmetic is `Double` (IEEE754) throughout, matching JS `Number` semantics — no +// `Decimal`/`Float80`. `value` is the shared bucket-value projection (seed 9999, same +// MurmurHash3 + scaling as the packed path) — reused unchanged, never recomputed here. + +import Foundation + +/// Namespace for the ANCHORED bucketing pass (contract v12+). Stateless — a pure selector, mirroring +/// `BucketingManager.selectBucket` for the packed pass, so it stays trivially testable with no +/// collaborators (no `EventSink`/`Logger`; the caller owns mapping the result back onto a +/// `Variation` and the tracking enqueue). +internal enum AnchoredBucketing { + /// One variation's resolved allocation weight and active/inactive flag under the anchored + /// layout — a named struct (not a tuple) so `large_tuple` stays satisfied. + private struct Allocation { + let id: String + let allocation: Double + let active: Bool + } + + /// Selects the variation id that `value` (a `0..<10000` bucket-unit, computed identically to + /// the packed path — hash/seed/scaling are untouched) falls into under the ANCHORED layout. + /// + /// - Parameters: + /// - variations: The experience's variations, in CONFIG ORDER, **unfiltered** — every entry + /// (active and inactive, with or without a `traffic_allocation`) must be passed through; + /// the anchored algorithm itself interprets active/inactive and NaN/absent allocation, + /// unlike the packed pass's pre-filtered `eligible` walk. + /// - value: The visitor's bucket value (`0..<10000`). + /// - Returns: The selected variation id, or `nil` when not bucketed. + static func selectBucket( + variations: [Components.Schemas.ExperienceVariationConfig], + value: Int + ) -> String? { + let allocations = buildAllocations(variations) + let totalWeight = allocations.reduce(0.0) { $0 + $1.allocation } + guard totalWeight > 0 else { + return nil + } + + let doubleValue = Double(value) + var cumWeight = 0.0 + for entry in allocations { + let anchor = (cumWeight / totalWeight) * Double(Defaults.maxTraffic) + let width = entry.active ? entry.allocation * 100.0 : 0.0 + if doubleValue >= anchor && doubleValue < anchor + width { + return entry.id + } + cumWeight += entry.allocation + } + return nil + } + + /// Builds the per-variation `{id, allocation, active}` triples (config order, entries with no + /// `id` dropped) that `selectBucket` walks — the direct mirror of the JS reference's + /// `_buildVariationAllocations`. + private static func buildAllocations( + _ variations: [Components.Schemas.ExperienceVariationConfig] + ) -> [Allocation] { + variations.compactMap { variation in + guard let id = variation.id else { + return nil + } + let allocation: Double + if let rawAllocation = variation.traffic_allocation, !rawAllocation.isNaN { + allocation = rawAllocation + } else { + allocation = 100.0 + } + let statusActive = variation.status == nil || variation.status == .running + let active = statusActive && allocation > 0 + return Allocation(id: id, allocation: allocation, active: active) + } + } +} diff --git a/Sources/ConvertSwiftSDKCore/Bucketing/BucketingManager.swift b/Sources/ConvertSwiftSDKCore/Bucketing/BucketingManager.swift index 0057fed..2f343b6 100644 --- a/Sources/ConvertSwiftSDKCore/Bucketing/BucketingManager.swift +++ b/Sources/ConvertSwiftSDKCore/Bucketing/BucketingManager.swift @@ -82,14 +82,31 @@ internal struct BucketingManager { Double(hashValue) / Double(Defaults.maxHash) * Double(Defaults.maxTraffic) ) - // 5. Keep only variations that carry BOTH an id and a traffic_allocation — a variation - // missing either can't be bucketed into. `traffic_allocation` is a 0–100 PERCENTAGE - // (see SCALE NOTE), so it is scaled `×100` into the 0..<10000 bucket-unit space the - // selector accumulates in — matching the JS/Android SDKs. Order is preserved. + // 5. Keep only variations that carry an id — a variation missing one can't be bucketed + // into. An omitted/NaN `traffic_allocation` defaults to 100.0 (qs-01 Phase 2 GREEN + // resolution — matches the JS reference's packed builder, + // `data-manager.ts:575`, `bucket[id] = traffic_allocation || 100.0`, whose include + // filter at L568-572 treats `isNaN(ta)` as included). `traffic_allocation` is a 0–100 + // PERCENTAGE (see SCALE NOTE), so it is scaled `×100` into the 0..<10000 bucket-unit + // space the selector accumulates in — matching the JS/Android SDKs. Order is preserved. + // A non-RUNNING variation (e.g. STOPPED) is also dropped here even when it carries a + // nonzero `traffic_allocation` — matches the JS reference's `_buildPackedBuckets` + // (`status === RUNNING` filter) and this SDK's own anchored path + // (`AnchoredBucketing.swift`'s `statusActive` check); a STOPPED arm must never be + // selectable regardless of its wire-configured allocation. let eligible: [WeightedVariation] = (experience.variations ?? []).compactMap { variation in - guard let key = variation.id, let allocation = variation.traffic_allocation else { + guard let key = variation.id else { return nil } + guard variation.status == nil || variation.status == .running else { + return nil + } + let allocation: Double + if let rawAllocation = variation.traffic_allocation, !rawAllocation.isNaN { + allocation = rawAllocation + } else { + allocation = 100.0 + } return WeightedVariation(key: key, weight: Int(allocation * 100), config: variation) } let weights = eligible.map { (key: $0.key, weight: $0.weight) } @@ -141,3 +158,73 @@ internal struct BucketingManager { return nil } } + +// MARK: - Version gate (qs-01, cross-SDK bucketing contract v12) + +extension BucketingManager { + /// Routes to the ANCHORED pass (contract `version > 11`) via ``AnchoredBucketing``, or + /// delegates VERBATIM to the existing packed ``bucket(visitorId:experience:enableTracking:)`` + /// above for `version <= 11` / missing / non-numeric (a `Double?` can never decode a + /// non-numeric wire value as non-nil, so "non-numeric" collapses into "missing" at this + /// layer; a `NaN` version — not reachable via JSON but defensively handled — also falls + /// through here since every comparison against `NaN` is `false`). The packed `eligible` walk + /// and ``selectBucket(weights:value:)`` above are UNTOUCHED (AC6) — this is a pure ADDITIONAL + /// branch, never a modification of the packed one. + /// + /// On a successful anchored selection, maps the id back onto its config and enqueues exactly + /// one `.bucketing` event when `enableTracking` — mirroring the packed pass's steps 6-9 + /// (AC9: unchanged event shape). No selection (not-bucketed) degrades to `nil`, enqueuing + /// nothing, same as the packed pass. + func bucketVersionGated( + visitorId: String, + experience: Components.Schemas.ConfigExperience, + enableTracking: Bool = true + ) async -> Variation? { + guard let version = experience.version, version > 11 else { + return await bucket(visitorId: visitorId, experience: experience, enableTracking: enableTracking) + } + + // An experience with no id cannot be hashed or attributed — degrade to nil (mirrors + // packed step 1). + guard let experienceId = experience.id else { + return nil + } + + // Hash "" with the shared seed, project onto 0..<10000 — + // byte-for-byte identical to `bucket(...)`'s steps 2-4. + let input = Array("\(experienceId)\(visitorId)".utf8) + let hashValue = MurmurHash3.hash(input, seed: Defaults.hashSeed) + let bucketValue = Int( + Double(hashValue) / Double(Defaults.maxHash) * Double(Defaults.maxTraffic) + ) + + // Select under the ANCHORED layout — every variation passed through unfiltered (the + // anchored algorithm itself interprets active/inactive and NaN/absent allocation). + let allVariations = experience.variations ?? [] + guard let selectedId = AnchoredBucketing.selectBucket(variations: allVariations, value: bucketValue) else { + return nil + } + + // Map the selected id back onto its config and build the result variation (mirrors + // packed step 7). + guard let selected = allVariations.first(where: { $0.id == selectedId }) else { + return nil + } + let variation = Variation( + id: selectedId, + key: selected.key ?? "", + experienceId: experienceId, + experienceKey: experience.key ?? "" + ) + + // Emit exactly one bucketing event when tracking is enabled; otherwise stay silent + // (mirrors packed step 8, AC9's unchanged event shape). + if enableTracking { + let data = BucketingEventData(experienceId: experienceId, variationId: selectedId) + await eventSink.enqueue(.bucketing(data), for: visitorId, segments: nil) + } + + // Return the resolved variation (mirrors packed step 9). + return variation + } +} diff --git a/Sources/ConvertSwiftSDKCore/Config/ConvertConfiguration.swift b/Sources/ConvertSwiftSDKCore/Config/ConvertConfiguration.swift index 6c7ae4b..a4626fb 100644 --- a/Sources/ConvertSwiftSDKCore/Config/ConvertConfiguration.swift +++ b/Sources/ConvertSwiftSDKCore/Config/ConvertConfiguration.swift @@ -56,6 +56,11 @@ public struct ConvertConfiguration: Sendable { public let networkTracking: Bool /// CDN cache level applied to config fetches. public let networkCacheLevel: CacheLevel + /// Optional QA debug token (qs-02 IOS-1). When set, config fetches carry a + /// `debug_token=` query param, force the CDN's low-cache bypass regardless of + /// ``networkCacheLevel``, and are never read from or written to the on-disk cache — + /// `nil` selects the ordinary (non-debug) transport and caching behavior. + public let debugToken: String? /// Creates a configuration, defaulting every field except ``sdkKey`` to its JS-parity value. /// - Parameters: @@ -76,6 +81,7 @@ public struct ConvertConfiguration: Sendable { /// - logLevel: Log severity threshold. /// - networkTracking: Whether event/network tracking is enabled. /// - networkCacheLevel: CDN cache level for config fetches. + /// - debugToken: Optional QA debug token; defaults to `nil` (ordinary transport/caching). public init( sdkKey: String, sdkKeySecret: String? = nil, @@ -91,7 +97,8 @@ public struct ConvertConfiguration: Sendable { ruleNegation: Bool = false, logLevel: LogLevel = .warn, networkTracking: Bool = true, - networkCacheLevel: CacheLevel = .normal + networkCacheLevel: CacheLevel = .normal, + debugToken: String? = nil ) { self.sdkKey = sdkKey self.sdkKeySecret = sdkKeySecret @@ -108,5 +115,6 @@ public struct ConvertConfiguration: Sendable { self.logLevel = logLevel self.networkTracking = networkTracking self.networkCacheLevel = networkCacheLevel + self.debugToken = debugToken } } diff --git a/Sources/ConvertSwiftSDKCore/Data/ProjectConfig+AudienceDecoding.swift b/Sources/ConvertSwiftSDKCore/Data/ProjectConfig+AudienceDecoding.swift new file mode 100644 index 0000000..9bdf5fd --- /dev/null +++ b/Sources/ConvertSwiftSDKCore/Data/ProjectConfig+AudienceDecoding.swift @@ -0,0 +1,90 @@ +// ProjectConfig+AudienceDecoding.swift +// Hand-authored per-audience DEGRADING decode (D5, IOS-1 mobile mutual-exclusion, mirrors the +// `rawExperiences` per-element loop in ProjectConfig.swift). Foundation-only — part of the +// pure-logic ConvertSwiftSDKCore target. +// +// Split into its OWN file purely to keep `ProjectConfig.swift` under SwiftLint's `file_length` / +// `function_body_length` gates — this logic is a direct extension of `ProjectConfig.init(from:)` +// (see the D5 note there), not an independent feature, and shares its `stringValue(of:in:)` +// helper (elevated from `private` to internal for that reuse — see the doc there). + +import Foundation + +extension ProjectConfig { + /// Decodes an ALREADY-OPENED `audiences` unkeyed container per-element through + /// `SentinelWrapped` (never throws — see + /// `PolymorphicSentinels.swift`) so a single audience whose rule tree embeds an unknown + /// `rule_type` discriminator (e.g. `bucketed_into_experience_key`) degrades out ALONE: + /// SIBLING audiences are retained untouched, and the offending audience is NOT dropped — it + /// is reconstructed as a placeholder (``reconstructAudience(fromSentinelPayload:)``) so it + /// stays retrievable via ``ProjectConfig/audience(id:)``, with its raw payload returned + /// alongside for ``ProjectConfig/degradedAudienceSentinels``. + /// + /// ── LOOP-TERMINATION INVARIANT (mirrors `DegradingExperience` in ProjectConfig.swift) ────── + /// `SentinelWrapped.init(from:)` NEVER throws on well-formed JSON (any failure to + /// decode `Known` falls back to `.sentinel`, capturing the raw payload instead of + /// propagating), so `rawAudiences.decode(SentinelWrapped + /// .self)` ALWAYS succeeds and advances the unkeyed-container index by EXACTLY one per + /// iteration — the `isAtEnd` guard is therefore guaranteed to flip after at most `count` + /// iterations and the loop terminates. The `try?` around the decode call is + /// defensive/unreachable (the decode cannot throw for a well-formed element). Do NOT rely on + /// a throwing decode here: it would leave the index un-advanced on a bad element and spin + /// this `while` FOREVER. + /// + /// - Parameter rawAudiences: The `audiences` field's unkeyed container, already opened by the + /// caller (`ProjectConfig.init(from:)`) via `container.nestedUnkeyedContainer(forKey: + /// .audiences)`. + /// - Returns: The retained audiences (`nil` when the array was empty or every element + /// degraded with no recoverable payload) and the sentinel payloads captured for degraded + /// audiences, keyed by their recovered `id` (`nil` when none degraded). + static func decodeDegradingAudiences( + from rawAudiences: inout UnkeyedDecodingContainer + ) -> (audiences: [Components.Schemas.ConfigAudience]?, sentinels: [String: JSONValue]?) { + var collectedAudiences: [Components.Schemas.ConfigAudience] = [] + var collectedSentinels: [String: JSONValue] = [:] + while !rawAudiences.isAtEnd { + if let wrapped = try? rawAudiences.decode( + SentinelWrapped.self + ) { + switch wrapped { + case let .known(audience): + collectedAudiences.append(audience) + case let .sentinel(payload): + let reconstructed = reconstructAudience(fromSentinelPayload: payload) + collectedAudiences.append(reconstructed) + if let id = reconstructed.id { + collectedSentinels[id] = payload + } + } + } + } + return ( + collectedAudiences.isEmpty ? nil : collectedAudiences, + collectedSentinels.isEmpty ? nil : collectedSentinels + ) + } + + /// Reconstructs a placeholder ``Components/Schemas/ConfigAudience`` from a `.sentinel` + /// payload's `id`/`key`/`name` members (D5) so an audience whose rule tree embeds an unknown + /// `rule_type` discriminator remains retrievable via ``ProjectConfig/audience(id:)`` instead + /// of being silently dropped. `rules` is left `nil`: the payload's rule tree is exactly the + /// sub-tree that failed to decode (that is WHY it sentineled) — a future rule-level consumer + /// (IOS-2) reads the raw leaf off ``ProjectConfig/degradedAudienceSentinels`` instead of this + /// placeholder's `rules`. `_type` is likewise left `nil` — not needed by any current consumer + /// of a degraded audience, and reconstructing it would require re-deriving a + /// `ConfigAudienceTypes` case from a raw String with no caller today to exercise it. + /// `id`/`key`/`name` are `nil` only when the payload is not a JSON object (a well-formed + /// audience is always an object) or omits that member. + static func reconstructAudience( + fromSentinelPayload payload: JSONValue + ) -> Components.Schemas.ConfigAudience { + guard case let .object(pairs) = payload else { + return Components.Schemas.ConfigAudience() + } + return Components.Schemas.ConfigAudience( + id: stringValue(of: "id", in: pairs), + key: stringValue(of: "key", in: pairs), + name: stringValue(of: "name", in: pairs) + ) + } +} diff --git a/Sources/ConvertSwiftSDKCore/Data/ProjectConfig.swift b/Sources/ConvertSwiftSDKCore/Data/ProjectConfig.swift index c14b34f..572a159 100644 --- a/Sources/ConvertSwiftSDKCore/Data/ProjectConfig.swift +++ b/Sources/ConvertSwiftSDKCore/Data/ProjectConfig.swift @@ -27,6 +27,16 @@ import Foundation /// disposition: the experience survives and its ``Experience/type`` degrades to `nil`. Once /// the serving-spec regen graduates `"a/b_fullstack"` to a first-class case it decodes /// cleanly — the degrade then applies only to type values the enum still lacks. +/// - **D5** (IOS-1, mobile mutual-exclusion) an `audiences[].rules` tree may embed a rule leaf +/// whose `rule_type` discriminator the generated `RuleElementAudience` `oneOf` does not carry +/// (e.g. `bucketed_into_experience_key`) → a raw element decode throws +/// `unknownOneOfDiscriminator`. Disposition: the OFFENDING audience alone degrades — decoded +/// per-element through `SentinelWrapped` (never throws), +/// it is reconstructed as a placeholder `ConfigAudience` (its `id`/`key`/`name` recovered from +/// the captured `.sentinel` payload, `rules` left `nil`) so it stays retrievable via +/// ``audience(id:)``, while the raw payload is retained in ``degradedAudienceSentinels`` for a +/// future rule-level consumer (IOS-2) to read the unknown leaf's `rule_type`/`value`/`negated`. +/// Every sibling audience — including ones before/after it in the array — decodes untouched. /// /// ── How the degrade is localized (NOT a boundary catch) ────────────────────────────────── /// Every degrade is a per-field `try?` inside a typed `init(from:)`, mirroring the sanctioned @@ -60,8 +70,22 @@ public struct ProjectConfig: Decodable, Sendable { /// whole-array `dataCorrupted` throw nulling its valid siblings. `nil` when the field is absent or every /// element degraded. Queried via ``fullExperience(forKey:)``. public var rawExperiences: [Components.Schemas.ConfigExperience]? - /// Audiences (wire `audiences`) — the generated element type decodes cleanly in the baseline. + /// Audiences (wire `audiences`, D5): decoded PER-ELEMENT through + /// `SentinelWrapped` (never throws — see + /// `PolymorphicSentinels.swift`) so a single audience whose rule tree embeds an unknown + /// `rule_type` discriminator degrades out ALONE instead of nulling the whole array (the prior + /// whole-array `try?` regression). A degraded audience is NOT dropped — it is reconstructed as + /// a placeholder carrying its recovered `id`/`key`/`name` (see ``degradedAudienceSentinels`` + /// for the raw payload) — so every audience, known or degraded, remains retrievable via + /// ``audience(id:)``. `nil` only when the field is absent or the array is empty. public var audiences: [Components.Schemas.ConfigAudience]? + /// Raw `.sentinel` payloads for audiences retained in ``audiences`` whose rule tree embedded an + /// unknown `rule_type` discriminator (D5), keyed by the audience's recovered `id`. Not consumed + /// by this task (IOS-1) — retained so a future rule-level consumer (IOS-2) can read the + /// unknown leaf's `rule_type`/`value`/`negated` off the captured `JSONValue`. `nil` when no + /// audience degraded to a sentinel, or when a degraded audience's payload lacked a recoverable + /// `id`. + public var degradedAudienceSentinels: [String: JSONValue]? /// Segments (wire `segments`) — the generated element type decodes cleanly in the baseline. public var segments: [Components.Schemas.ConfigSegment]? /// Locations (wire `locations`) — the generated element type decodes cleanly in the baseline. @@ -152,10 +176,17 @@ public struct ProjectConfig: Decodable, Sendable { } rawExperiences = collected.isEmpty ? nil : collected } - audiences = try? container.decodeIfPresent( - [Components.Schemas.ConfigAudience].self, - forKey: .audiences - ) + // D5: retain EVERY audience, decoded PER-ELEMENT so a single audience whose rule tree + // embeds an unrecognised `rule_type` leaf degrades out ALONE instead of a whole-array + // `try?` nulling every sibling. The per-element loop + its LOOP-TERMINATION INVARIANT live + // in `ProjectConfig+AudienceDecoding.swift` (split out to stay under SwiftLint's + // file/function length gates — the logic is a direct extension of this decode, not a + // separate feature). + if var rawAudiences = try? container.nestedUnkeyedContainer(forKey: .audiences) { + let decodedAudiences = Self.decodeDegradingAudiences(from: &rawAudiences) + audiences = decodedAudiences.audiences + degradedAudienceSentinels = decodedAudiences.sentinels + } segments = try? container.decodeIfPresent( [Components.Schemas.ConfigSegment].self, forKey: .segments @@ -259,8 +290,11 @@ public struct ProjectConfig: Decodable, Sendable { /// The `String` value of the `name`-keyed member in a `JSONValue` object's pairs, or `nil` when /// the member is absent or not a JSON string. Centralizes the `.object` member read so the - /// sentinel reconstruction never inlines the find-then-unwrap per field. - private static func stringValue(of name: String, in pairs: [JSONValue.Pair]) -> String? { + /// sentinel reconstruction never inlines the find-then-unwrap per field. Internal (not + /// `private`) so ``ProjectConfig/decodeDegradingAudiences(from:)`` in + /// `ProjectConfig+AudienceDecoding.swift` can reuse it for the D5 audience reconstruction + /// without duplicating the same four-line lookup. + static func stringValue(of name: String, in pairs: [JSONValue.Pair]) -> String? { guard case let .string(value)? = pairs.first(where: { $0.key == name })?.value else { return nil } diff --git a/Sources/ConvertSwiftSDKCore/Experience/ExperienceManager+MutualExclusion.swift b/Sources/ConvertSwiftSDKCore/Experience/ExperienceManager+MutualExclusion.swift new file mode 100644 index 0000000..46121d3 --- /dev/null +++ b/Sources/ConvertSwiftSDKCore/Experience/ExperienceManager+MutualExclusion.swift @@ -0,0 +1,69 @@ +// ExperienceManager+MutualExclusion.swift +// The degraded-audience rule extraction (IOS-3, M2 integration, iOS mutual-exclusion qs-03) and +// the whole-audience-override DETECTION helpers (M2, iOS mutual-exclusion qs-04 +// re-architecture). Split into its OWN file purely to keep `ExperienceManager.swift` under +// SwiftLint's `file_length` gate — mirrors the `ProjectConfig+AudienceDecoding.swift` / +// `RuleAdapter+JSONSentinelFlatten.swift` split precedent (IOS-1/IOS-2): a fresh file for a +// small, cohesive helper rather than growing an already-large one. `internal` (not `private`), +// since these are called from `ExperienceManager.audiencePasses(_:in:attributes:storeKey:)` in +// the sibling file — `private` is file-scoped in Swift and would not be reachable across files. +// +// Foundation-only — pure mapping over decoded `JSONValue`/`RuleGroup` values; no platform +// framework, no state. + +import Foundation + +extension ExperienceManager { + /// Extracts the `"rules"` sub-tree from a degraded audience's sentinel-captured payload (the + /// FULL `ConfigAudience` JSON object — id/key/name/rules, per ``ProjectConfig + /// /degradedAudienceSentinels``) and flattens it via ``RuleAdapter/flatten(_:)`` (the + /// `JSONValue` overload, IOS-2). A non-object payload, or one whose `"rules"` member is absent + /// or malformed, degrades to no groups — fail-closed, same as the typed path's `nil`-`rules` + /// guard in ``flattenedGroups(for:in:)``. + static func flattenDegradedAudienceRules(_ sentinelPayload: JSONValue) -> [RuleGroup] { + guard case let .object(pairs) = sentinelPayload, + let rulesValue = pairs.first(where: { $0.key == "rules" })?.value else { + return [] + } + return RuleAdapter.flatten(rulesValue) + } + + /// Flattens ONE attached audience's rule tree into `[RuleGroup]`, dispatching to the + /// DEGRADED sentinel path (``flattenDegradedAudienceRules(_:)``) when the audience's typed + /// decode degraded (its tree embedded an unrecognised `rule_type` leaf, e.g. + /// `bucketed_into_experience_key` — IOS-1), or the typed ``RuleAdapter/flatten(_:)`` path + /// otherwise — the ONLY dispatch difference; a normally-decoded audience's typed + /// `rules?.value1` is still flattened exactly as before (bit-identical, AC7). An absent + /// typed `rules` on a NON-degraded audience yields no groups (fail-closed). + static func flattenedGroups( + for audience: Components.Schemas.ConfigAudience, + in config: ProjectConfig + ) -> [RuleGroup] { + if let id = audience.id, let sentinel = config.degradedAudienceSentinels?[id] { + return flattenDegradedAudienceRules(sentinel) + } + guard let rules = audience.rules?.value1 else { return [] } + return RuleAdapter.flatten(rules) + } + + /// Finds the first STATEFUL leaf (`bucketed_into_experience_key`) in an already-flattened + /// audience's groups, if any (M2, iOS mutual-exclusion qs-04 re-architecture). An audience + /// carrying one is a whole-audience EXCLUSION audience whose ENTIRE match is resolved via + /// ``BucketingExclusion/resolve(targetExperienceKey:negated:resolver:logger:)`` alone — + /// mirroring JS's `_isBucketingExclusionRule` (`data-manager.ts:1246-1265`), which likewise + /// walks the WHOLE audience tree for the first such leaf and, if found, never evaluates any + /// sibling leaf in the same tree through the generic engine. Returns `nil` for a generic + /// (non-stateful) audience, however many groups/conditions it carries. + static func statefulLeaf( + in groups: [RuleGroup] + ) -> (targetExperienceKey: String, negated: Bool)? { + for group in groups { + for condition in group.conditions { + if let target = condition.statefulTarget { + return (target.targetExperienceKey, condition.negation) + } + } + } + return nil + } +} diff --git a/Sources/ConvertSwiftSDKCore/Experience/ExperienceManager.swift b/Sources/ConvertSwiftSDKCore/Experience/ExperienceManager.swift index 73e50a9..0ee79a8 100644 --- a/Sources/ConvertSwiftSDKCore/Experience/ExperienceManager.swift +++ b/Sources/ConvertSwiftSDKCore/Experience/ExperienceManager.swift @@ -12,8 +12,10 @@ // the bd-d4p empty-list rule); a non-empty set's rules are flattened and OR-combined across // every attached audience, then evaluated against `attributes` (a fail returns nil). // 4. LOCATION gate: the same shape over `locations` against `locationProperties` (empty ⇒ pass). -// 5. BUCKET via ``BucketingManager/bucket(visitorId:experience:enableTracking:)`` — that call -// owns the single bucketing enqueue (driven by `enableTracking`); this type NEVER enqueues. +// 5. BUCKET via ``BucketingManager/bucketVersionGated(visitorId:experience:enableTracking:)`` +// (qs-01: routes `version > 11` to the ANCHORED layout, `version <= 11`/missing delegates +// verbatim to the packed walk) — that call owns the single bucketing enqueue (driven by +// `enableTracking`); this type NEVER enqueues. // 6. PERSIST the new decision; FIRE `.bucketing` on the bus — only on a NEW decision. // // SCOPE (bd-d4p) — the audience/location combine is a FLAT OR across the attached objects' @@ -139,6 +141,17 @@ public struct ExperienceManager: Sendable { /// - locationProperties: The data map the location gate evaluates against. /// - enableTracking: When `false`, suppresses the bucketing enqueue (passed through to the /// bucket step); the variation is still selected, persisted, and fired. + /// - persistDecision: When `false`, suppresses the sticky-decision ``DecisionStore/saveDecision`` + /// WRITE on a NEW decision (qs-02 IOS-6, AC6 zero-trace gate) — the variation is still + /// selected and the `.bucketing` event still fires; only the disk write is skipped. Sticky + /// READS (the short-circuit above) are unaffected. Defaults to `true` so every existing + /// call site — unaware of preview — persists exactly as before (AC10 regression safety). + /// - emitBucketing: When `false`, suppresses the `.bucketing` ``EventBus`` fire on a NEW + /// decision (qs-02 Fix 1, JS parity: `context.ts` wraps every `SystemEvents.BUCKETING` emit + /// in `if (!this._preview)`) — the variation is still selected and persisted (subject to + /// `persistDecision`); only the observer notification is skipped. Defaults to `true` so + /// every existing call site — unaware of preview — fires exactly as before (AC10 regression + /// safety). /// - Returns: The assigned ``Variation``, or `nil` on any short-circuit / gate failure / miss. public func selectVariation( // swiftlint:disable:this function_parameter_count forKey key: String, @@ -148,7 +161,9 @@ public struct ExperienceManager: Sendable { projectId: String, attributes: [String: String], locationProperties: [String: String], - enableTracking: Bool + enableTracking: Bool, + persistDecision: Bool = true, + emitBucketing: Bool = true ) async -> Variation? { // 1. Resolve the full experience and its id (the id keys sticky / persist). guard let full = config.fullExperience(forKey: key), let experienceId = full.id else { @@ -164,28 +179,36 @@ public struct ExperienceManager: Sendable { } // 3–4. AUDIENCE then LOCATION gate (an empty resolved set is unrestricted / passes). - guard audiencePasses(full, in: config, attributes: attributes), + guard await audiencePasses(full, in: config, attributes: attributes, storeKey: storeKey), locationPasses(full, in: config, locationProperties: locationProperties) else { return nil } - // 5. BUCKET — this performs the single enqueue when `enableTracking`; a miss returns nil. - guard let variation = await bucketingManager.bucket( + // 5. BUCKET — routed through the version gate (qs-01): `version > 11` runs the ANCHORED + // layout, `version <= 11`/missing delegates verbatim to the packed walk (AC6). This + // performs the single enqueue when `enableTracking`; a miss returns nil. + guard let variation = await bucketingManager.bucketVersionGated( visitorId: visitorId, experience: full, enableTracking: enableTracking ) else { return nil } - // 6. PERSIST the new decision, then FIRE `.bucketing` (only on a NEW decision). - await decisionStore.saveDecision( - variationId: variation.id, experienceId: experienceId, storeKey: storeKey - ) - await eventBus.fire( - .bucketing, - payload: .bucketing(BucketingPayload( - experienceId: experienceId, variationId: variation.id, visitorId: visitorId - )) - ) + // 6. PERSIST the new decision (qs-02 IOS-6: skipped under preview via `persistDecision`), + // then FIRE `.bucketing` on a NEW decision — gated by `emitBucketing` (qs-02 Fix 1: + // skipped under preview, independent of `persistDecision`). + if persistDecision { + await decisionStore.saveDecision( + variationId: variation.id, experienceId: experienceId, storeKey: storeKey + ) + } + if emitBucketing { + await eventBus.fire( + .bucketing, + payload: .bucketing(BucketingPayload( + experienceId: experienceId, variationId: variation.id, visitorId: visitorId + )) + ) + } return variation } @@ -229,6 +252,12 @@ public struct ExperienceManager: Sendable { /// - locationProperties: The data map each experience's location gate evaluates against. /// - enableTracking: Forwarded UNCHANGED to every per-experience bucket; when `false` the bucketing /// enqueue is suppressed (the variation is still selected, persisted, and fired). + /// - persistDecision: Forwarded UNCHANGED to every per-experience ``selectVariation`` call + /// (qs-02 IOS-6, AC6 zero-trace gate); defaults to `true` so every existing call site + /// persists exactly as before (AC10 regression safety). + /// - emitBucketing: Forwarded UNCHANGED to every per-experience ``selectVariation`` call + /// (qs-02 Fix 1, JS parity); defaults to `true` so every existing call site fires exactly + /// as before (AC10 regression safety). /// - Returns: The assigned ``Variation`` for every eligible experience, in config order; `[]` when /// the config has no experiences or the visitor is eligible for none. public func selectVariations( // swiftlint:disable:this function_parameter_count @@ -238,7 +267,9 @@ public struct ExperienceManager: Sendable { projectId: String, attributes: [String: String], locationProperties: [String: String], - enableTracking: Bool + enableTracking: Bool, + persistDecision: Bool = true, + emitBucketing: Bool = true ) async -> [Variation] { guard let experiences = config.rawExperiences, !experiences.isEmpty else { return [] } var results: [Variation] = [] @@ -256,7 +287,9 @@ public struct ExperienceManager: Sendable { projectId: projectId, attributes: attributes, locationProperties: locationProperties, - enableTracking: enableTracking + enableTracking: enableTracking, + persistDecision: persistDecision, + emitBucketing: emitBucketing ) { results.append(variation) } @@ -289,22 +322,56 @@ public struct ExperienceManager: Sendable { /// Whether the experience's AUDIENCE gate passes for `attributes`. /// - /// An EMPTY resolved audience set is UNRESTRICTED → `true` (parity: an experience with no - /// audiences runs for everyone). Otherwise every attached audience's rules are flattened and - /// CONCATENATED into one outer-OR (the visitor matches if ANY audience's rules match), then - /// evaluated by ``RuleManager`` (which fails closed on an empty group). + /// An EMPTY resolved audience set is UNRESTRICTED → `true`. Otherwise EVERY attached + /// audience is resolved to its OWN match boolean via ``ExperienceManager + /// /flattenedGroups(for:in:)`` + ``ExperienceManager/statefulLeaf(in:)`` (whole-audience + /// EXCLUSION override through ``BucketingExclusion/resolve(targetExperienceKey:negated: + /// resolver:logger:)`` when a stateful leaf is detected; the generic ``RuleManager`` path + /// otherwise — see those declarations for the full JS-parity rationale, M2 iOS + /// mutual-exclusion qs-04), then composed via `full.settings?.matching_options?.audiences` + /// (JS `data-manager.ts:418-428`): `.all` requires every audience to match; `.any`/absent + /// requires only one. + /// + /// The visitor's sticky-bucketing SNAPSHOT is pre-fetched ONCE per call (a PURE read — no + /// LRU touch, no write, no bucketing, no tracking event; AC5) via ``DecisionStore + /// /bucketingDecisions(forStoreKey:)`` and closed over by a synchronous resolver: an unknown + /// target experience key resolves `nil` (warns, naming the key — AC8); a known target + /// resolves whether ITS id is a key in the pre-fetched snapshot. private func audiencePasses( _ full: Components.Schemas.ConfigExperience, in config: ProjectConfig, - attributes: [String: String] - ) -> Bool { + attributes: [String: String], + storeKey: String + ) async -> Bool { let audiences = (full.audiences ?? []).compactMap { config.audience(id: $0) } guard !audiences.isEmpty else { return true } - let groups = audiences.flatMap { audience -> [RuleGroup] in - guard let rules = audience.rules?.value1 else { return [] } - return RuleAdapter.flatten(rules) + + let bucketing = await decisionStore.bucketingDecisions(forStoreKey: storeKey) + let resolver: (String) -> Bool? = { targetExperienceKey in + guard let target = config.fullExperience(forKey: targetExperienceKey), + let targetId = target.id else { + return nil + } + return bucketing[targetId] != nil + } + + let matches = audiences.map { audience -> Bool in + let groups = Self.flattenedGroups(for: audience, in: config) + if let leaf = Self.statefulLeaf(in: groups) { + return BucketingExclusion.resolve( + targetExperienceKey: leaf.targetExperienceKey, + negated: leaf.negated, + resolver: resolver, + logger: logger + ) + } + return ruleManager.evaluate(rules: groups, against: attributes) + } + + if full.settings?.matching_options?.audiences == .all { + return matches.allSatisfy { $0 } } - return ruleManager.evaluate(rules: groups, against: attributes) + return matches.contains(true) } /// Whether the experience's LOCATION gate passes for `locationProperties`. diff --git a/Sources/ConvertSwiftSDKCore/Experience/FeatureManager.swift b/Sources/ConvertSwiftSDKCore/Experience/FeatureManager.swift index 0574eb7..6b6560b 100644 --- a/Sources/ConvertSwiftSDKCore/Experience/FeatureManager.swift +++ b/Sources/ConvertSwiftSDKCore/Experience/FeatureManager.swift @@ -79,6 +79,16 @@ public struct FeatureManager: Sendable { /// - projectId: Project id — forwarded to `selectVariation` (sticky store key segment). /// - attributes: The data map each carrying experience's audience gate evaluates against. /// - locationProperties: The data map each carrying experience's location gate evaluates against. + /// - enableTracking: Forwarded to the delegated `selectVariation` call — suppresses the + /// bucketing enqueue at the source (e.g. under experiment preview, qs-02 IOS-fix2) while the + /// variation is still selected. Defaults to `true` (today's behavior, unchanged for every + /// other caller). + /// - persistDecision: Forwarded to the delegated `selectVariation` call — suppresses the sticky + /// decision WRITE at the source (e.g. under experiment preview). Defaults to `true` (today's + /// behavior, unchanged for every other caller). + /// - emitBucketing: Forwarded to the delegated `selectVariation` call — suppresses the + /// `.bucketing` `EventBus` fire at the source (qs-02 Fix 1, e.g. under experiment preview). + /// Defaults to `true` (today's behavior, unchanged for every other caller). /// - Returns: The resolved ``Feature`` — `.enabled` with typed variables, or `.disabled`. public func evaluateFeature( // swiftlint:disable:this function_parameter_count key: String, @@ -87,7 +97,10 @@ public struct FeatureManager: Sendable { accountId: String, projectId: String, attributes: [String: String], - locationProperties: [String: String] + locationProperties: [String: String], + enableTracking: Bool = true, + persistDecision: Bool = true, + emitBucketing: Bool = true ) async -> Feature { // 1. Look up the feature; a miss is a population-layer warning, then disabled. guard let feature = config.features?.first(where: { $0.key == key }) else { @@ -112,7 +125,9 @@ public struct FeatureManager: Sendable { projectId: projectId, attributes: attributes, locationProperties: locationProperties, - enableTracking: true + enableTracking: enableTracking, + persistDecision: persistDecision, + emitBucketing: emitBucketing ) // 3c. Visitor not bucketed into this carrier — a later experience might still carry it. guard let variation else { continue } @@ -145,6 +160,15 @@ public struct FeatureManager: Sendable { /// - projectId: Project id — forwarded to each `evaluateFeature`. /// - attributes: The data map each feature's carrying experiences' audience gates evaluate against. /// - locationProperties: The data map each feature's carrying experiences' location gates evaluate against. + /// - enableTracking: Forwarded to each ``evaluateFeature`` call — suppresses the bucketing + /// enqueue at the source (e.g. under experiment preview, qs-02 IOS-fix2). Defaults to `true` + /// (today's behavior, unchanged for every other caller). + /// - persistDecision: Forwarded to each ``evaluateFeature`` call — suppresses the sticky + /// decision WRITE at the source (e.g. under experiment preview). Defaults to `true` (today's + /// behavior, unchanged for every other caller). + /// - emitBucketing: Forwarded to each ``evaluateFeature`` call — suppresses the `.bucketing` + /// `EventBus` fire at the source (qs-02 Fix 1, e.g. under experiment preview). Defaults to + /// `true` (today's behavior, unchanged for every other caller). /// - Returns: One ``Feature`` per `config.features` entry, in config order; `[]` when empty. public func evaluateAllFeatures( // swiftlint:disable:this function_parameter_count in config: ProjectConfig, @@ -152,7 +176,10 @@ public struct FeatureManager: Sendable { accountId: String, projectId: String, attributes: [String: String], - locationProperties: [String: String] + locationProperties: [String: String], + enableTracking: Bool = true, + persistDecision: Bool = true, + emitBucketing: Bool = true ) async -> [Feature] { guard let features = config.features, !features.isEmpty else { return [] } var results: [Feature] = [] @@ -166,7 +193,10 @@ public struct FeatureManager: Sendable { accountId: accountId, projectId: projectId, attributes: attributes, - locationProperties: locationProperties + locationProperties: locationProperties, + enableTracking: enableTracking, + persistDecision: persistDecision, + emitBucketing: emitBucketing ) results.append(resolved) } diff --git a/Sources/ConvertSwiftSDKCore/Logging/ToLoggable.swift b/Sources/ConvertSwiftSDKCore/Logging/ToLoggable.swift index bfed9f0..5c65395 100644 --- a/Sources/ConvertSwiftSDKCore/Logging/ToLoggable.swift +++ b/Sources/ConvertSwiftSDKCore/Logging/ToLoggable.swift @@ -66,21 +66,50 @@ private func maskedKey(for token: String) -> String { return "sk_" + redactionEllipsis + String(material.suffix(4)) } -/// Replaces the value of secret-bearing query params (`sdkKeySecret`, `sdkKey`) with `…`. +/// Replaces the value of secret-bearing query params (`sdkKeySecret`, `sdkKey`) with `…`, and +/// strips `debug_token=` (qs-02 IOS-1, AC3) ENTIRELY — param name included, not just the +/// value — so a redacted log line never carries the `debug_token=` substring at all. `sdkKeySecret` +/// / `sdkKey` keep their param name (only the value is masked); `debug_token` does not, because +/// (unlike a rotatable API secret) the token IS the QA session identifier and its param name +/// alone is enough to fingerprint a debug session in log aggregation, so this leaves nothing +/// behind (see `DebugTokenRedactionTests.warnLineDoesNotLeakDebugToken`). +/// +/// One alternation (not two independently-run regexes) so future secret-param additions stay a +/// single-pattern extension; the two alternatives are told apart per-match via the presence / +/// absence of capture group 1, which only the `sdkKeySecret|sdkKey` branch populates. +/// +/// The `debug_token` branch is anchored with a negative lookbehind on a preceding word character +/// (`(?` is still fully stripped +/// whether it sits right after `?`, after `&`, or at the very start of the string (no preceding +/// character at all). This is safer than an `(?<=[?&])`-style lookbehind (used on Android), +/// which would incorrectly MISS a token at the very start of a string with no leading `?`/`&`. private func stripSecretQueryParams(from value: String) -> String { - // Match `=` up to the next `&`, `#`, whitespace, or end of string. + // Match `=` up to the next `&`, `#`, whitespace, or end of string. Group 1 + // captures the masked-param-name branch only; the `debug_token` branch has no capture group, + // so `match.range(at: 1)` is `NSNotFound` for it (the per-match discriminator below). guard let regex = try? NSRegularExpression( - pattern: "(sdkKeySecret|sdkKey)=[^&#\\s]*" + pattern: "(?:(sdkKeySecret|sdkKey)=[^&#\\s]*)|(?` pair, name included. + result.replaceSubrange(matchRange, with: redactionEllipsis) + } + } + return result } diff --git a/Sources/ConvertSwiftSDKCore/Models/RuleCondition.swift b/Sources/ConvertSwiftSDKCore/Models/RuleCondition.swift index ac9e822..24ec148 100644 --- a/Sources/ConvertSwiftSDKCore/Models/RuleCondition.swift +++ b/Sources/ConvertSwiftSDKCore/Models/RuleCondition.swift @@ -17,4 +17,25 @@ internal struct RuleCondition: Sendable, Equatable { let value: String? /// When `true`, inverts the comparator's result. let negation: Bool + /// Non-nil for a STATEFUL leaf (today only `bucketed_into_experience_key`, IOS-2 / qs-03 + /// mobile mutual-exclusion): the leaf is resolved by ``RuleManager`` against an injected + /// bucketing-decision resolver instead of `attributes[key]` -> ``Comparisons``. `nil` for + /// every generic (attribute-lookup) leaf — additive/defaulted so every pre-existing 4-arg + /// call site keeps compiling unchanged. + let statefulTarget: StatefulRuleTarget? + + /// Explicit initializer (NOT the synthesized memberwise init): a `let` stored property with + /// an `= nil` default is NOT overridable through Swift's synthesized memberwise + /// initializer — verified empirically (a defaulted `let` parameter is dropped from that + /// init's signature entirely, so passing it explicitly fails to compile with "extra + /// argument"). A hand-written `init` with a default PARAMETER value has no such + /// restriction: it both omits cleanly (existing 4-arg call sites) and accepts an explicit + /// override (the stateful call sites in `RuleAdapter`/tests). + init(key: String, matchType: String, value: String?, negation: Bool, statefulTarget: StatefulRuleTarget? = nil) { + self.key = key + self.matchType = matchType + self.value = value + self.negation = negation + self.statefulTarget = statefulTarget + } } diff --git a/Sources/ConvertSwiftSDKCore/Models/StatefulRuleTarget.swift b/Sources/ConvertSwiftSDKCore/Models/StatefulRuleTarget.swift new file mode 100644 index 0000000..f0fa9be --- /dev/null +++ b/Sources/ConvertSwiftSDKCore/Models/StatefulRuleTarget.swift @@ -0,0 +1,22 @@ +// StatefulRuleTarget.swift +// The stateful-leaf payload for a `bucketed_into_experience_key` rule condition (IOS-2, qs-03 +// mobile mutual-exclusion). Foundation-only — part of the pure-logic ConvertSwiftSDKCore target. + +import Foundation + +/// Carries the target-experience-KEY payload of a STATEFUL rule leaf (today only +/// `bucketed_into_experience_key`). Unlike every other ``RuleCondition`` leaf, this one is +/// resolved not by looking `key` up in `attributes` and dispatching through ``Comparisons``, but +/// by a whole-audience-override seam that queries an injected bucketing-decision resolver +/// (``BucketingExclusion/resolve(targetExperienceKey:negated:resolver:logger:)``, detected via +/// ``ExperienceManager/statefulLeaf(in:)`` — M2, iOS mutual-exclusion qs-04 re-architecture; +/// `RuleManager` itself no longer consults this leaf at all, see qs-03/qs-04). +internal struct StatefulRuleTarget: Sendable, Equatable { + /// The wire `rule_type` discriminator (today always `"bucketed_into_experience_key"`) — + /// carried as a forward-compat marker in case a sibling stateful rule type is added later. + let ruleType: String + /// The TARGET EXPERIENCE KEY (`rule.value`) — a KEY, not an id. iOS is id-keyed internally; + /// resolving this key to an id (and then to a bucketing decision) is the resolver's job + /// (IOS-3), not this type's. + let targetExperienceKey: String +} diff --git a/Sources/ConvertSwiftSDKCore/Preview/PreviewDecision.swift b/Sources/ConvertSwiftSDKCore/Preview/PreviewDecision.swift new file mode 100644 index 0000000..a3ef941 --- /dev/null +++ b/Sources/ConvertSwiftSDKCore/Preview/PreviewDecision.swift @@ -0,0 +1,60 @@ +// PreviewDecision.swift +// Pure forced-variation decision primitive for experiment preview (qs-02 Experiment Preview, +// contract §2 "Decision" clause / AC4 / AC5), task IOS-3. Foundation-only — part of the +// pure-logic ConvertSwiftSDKCore target. +// +// SCOPE (IOS-3 — narrower than the full qs-02 preview surface): this primitive matches a +// `variationId` against one already-resolved `Components.Schemas.ConfigExperience` and nothing +// else. It takes no `visitorId`, no attributes/environment/locationProperties, and no +// decision-store input — so audiences, segments, locations, the environment check, experience +// status, variation status/traffic filters, stored decisions, and the bucketing hash are all +// bypassed BY CONSTRUCTION: there is no reachable input for a rule gate, a bucketing walk, or a +// sticky-decision lookup to consult. Neither `BucketingManager` nor +// `ExperienceManager.selectVariation` is called or reachable from this signature. + +import Foundation + +/// Resolves a forced-variation decision for experiment preview, bypassing bucketing, rule +/// matching, and stored decisions entirely. +public enum PreviewDecision { + + /// Matches `variationId` against `experience.variations` and returns the corresponding + /// `Variation` unconditionally — never consulting experience status, environment, variation + /// status/traffic, or any stored decision. + /// + /// Field mapping mirrors the `Variation` shape `BucketingManager.bucket`/ + /// `bucketVersionGated` build from a normal bucketed decision + /// (`Sources/ConvertSwiftSDKCore/Bucketing/BucketingManager.swift:118-123`, `:205-210`): + /// optional `id`/`key` fields degrade to `""` rather than being force-unwrapped — EXCEPT + /// `experience.key`/`experience.id`, which is treated as bad input rather than degraded (see + /// below): a `Variation` carrying an empty `experienceKey` would poison + /// `ConvertContext.runExperiences`' sibling filter (`$0.key != forced.experienceKey` becomes + /// `$0.key != ""`, dropping every real-keyed sibling) and could never be matched by + /// `runExperience`'s `forced.experienceKey == key` short-circuit either. + /// + /// - Parameters: + /// - experience: the experience config to match `variationId` against. + /// - variationId: the id of the variation to force. + /// - Returns: the forced `Variation`, or `nil` when `variationId` is not present in + /// `experience.variations`, OR `experience.key` / `experience.id` is nil/empty + /// (inert-on-bad-input signal, same `nil` as an unmatched `variationId`). Never logs, never + /// throws. + public static func forcedVariation( + for experience: Components.Schemas.ConfigExperience, + variationId: String + ) -> Variation? { + guard let experienceKey = experience.key, !experienceKey.isEmpty, + let experienceId = experience.id, !experienceId.isEmpty else { + return nil + } + guard let matched = experience.variations?.first(where: { $0.id == variationId }) else { + return nil + } + return Variation( + id: matched.id ?? "", + key: matched.key ?? "", + experienceId: experienceId, + experienceKey: experienceKey + ) + } +} diff --git a/Sources/ConvertSwiftSDKCore/Preview/PreviewParam.swift b/Sources/ConvertSwiftSDKCore/Preview/PreviewParam.swift new file mode 100644 index 0000000..2716ded --- /dev/null +++ b/Sources/ConvertSwiftSDKCore/Preview/PreviewParam.swift @@ -0,0 +1,38 @@ +// PreviewParam.swift +// Pure parser for the `convert_preview={experienceId}.{variationId}` link param (qs-02 +// Experiment Preview, contract §2 / AC9). Foundation-only — part of the pure-logic +// ConvertSwiftSDKCore target. + +import Foundation + +/// Parses the `convert_preview` link param used to seed experiment previews. +public enum PreviewParam { + + /// Parses `convert_preview={experienceId}.{variationId}` — dot-separated numeric-id + /// strings, mirroring the web force-param format `_conv_eforce={expId}.{varId}`. + /// + /// - Parameter value: the raw param value, e.g. `"123.456"`. + /// - Returns: the `(experienceId, variationId)` pair, or `nil` if `value` is not exactly + /// two non-empty, all-digit components separated by a single `.`. + public static func parse(_ value: String) -> (experienceId: String, variationId: String)? { + let components = value.split(separator: ".", omittingEmptySubsequences: false) + guard components.count == 2 else { + return nil + } + + let experienceId = components[0] + let variationId = components[1] + + guard isNumericID(experienceId), isNumericID(variationId) else { + return nil + } + + return (experienceId: String(experienceId), variationId: String(variationId)) + } + + /// `true` when `component` is non-empty and every character is an ASCII decimal digit + /// (`0`-`9`). + private static func isNumericID(_ component: Substring) -> Bool { + !component.isEmpty && component.allSatisfy { $0.isASCII && $0.isNumber } + } +} diff --git a/Sources/ConvertSwiftSDKCore/Rules/BucketingExclusion.swift b/Sources/ConvertSwiftSDKCore/Rules/BucketingExclusion.swift new file mode 100644 index 0000000..629681f --- /dev/null +++ b/Sources/ConvertSwiftSDKCore/Rules/BucketingExclusion.swift @@ -0,0 +1,57 @@ +// BucketingExclusion.swift +// The whole-audience-override resolution seam for a `bucketed_into_experience_key` +// mutual-exclusion rule (M2, iOS mutual-exclusion qs-04 re-architecture). +// +// PARITY NOTE — mirrors the LIVE JS `_resolveBucketingExclusion` +// (`javascript-sdk/packages/data/src/data-manager.ts:1282-1304`, `feat/mutual-exclusion-rule`) +// almost verbatim: `bucketedRaw = resolver(targetExperienceKey)` (`nil` == unknown target -> +// WARN naming the key, default `bucketedRaw = false`; a resolver-returned `false` == a KNOWN +// target the visitor is simply not bucketed into -> NO warn); `matched = negated ? !bucketedRaw +// : bucketedRaw`, applied exactly once. +// +// This seam is RuleManager-INDEPENDENT by design (see the sibling +// `Tests/ConvertSwiftSDKCoreTests/Rules/MutualExclusionRuleManagerTests.swift` header for the +// full rework rationale): a whole audience whose tree carries this leaf resolves its ENTIRE +// match through this seam alone — sibling leaves in the same tree are never evaluated by any +// engine — mirroring JS's `filterMatchedRecordsWithRule` (`data-manager.ts:1336-1345`), which +// never calls the generic `isRuleMatched` for an exclusion audience. +// +// Foundation-only — a stateless `enum` namespace; no platform framework, no state. + +import Foundation + +/// Resolves a `bucketed_into_experience_key` whole-audience-override rule leaf. +/// +/// `resolver(key)` returns `Bool?`: `true` (the visitor is bucketed into the target), `false` +/// (a KNOWN target the visitor is simply not bucketed into — no warning), or `nil` (an unknown +/// target — WARNs naming the key, defaults to not-bucketed). +internal enum BucketingExclusion { + /// - Parameters: + /// - targetExperienceKey: The rule's `value` — the target experience KEY (not id). + /// - negated: The leaf's `matching.negated` flag, applied to `bucketedRaw` exactly once. + /// - resolver: Queried once for `targetExperienceKey`; `nil` means an unknown target. + /// - logger: Sink for the WARN line emitted only when `resolver` returns `nil`. + /// - Returns: `negated ? !bucketedRaw : bucketedRaw`, where `bucketedRaw` is the resolver's + /// result (defaulting to `false` for an unknown target). + static func resolve( + targetExperienceKey: String, + negated: Bool, + resolver: (String) -> Bool?, + logger: Logger + ) -> Bool { + let bucketedRaw: Bool + if let resolved = resolver(targetExperienceKey) { + bucketedRaw = resolved + } else { + logger.log( + level: .warn, + type: "BucketingExclusion", + method: "resolve", + message: "bucketed_into_experience_key: unknown target experience key " + + "'\(targetExperienceKey)', treating as not bucketed" + ) + bucketedRaw = false + } + return negated ? !bucketedRaw : bucketedRaw + } +} diff --git a/Sources/ConvertSwiftSDKCore/Rules/RuleAdapter+JSONSentinelFlatten.swift b/Sources/ConvertSwiftSDKCore/Rules/RuleAdapter+JSONSentinelFlatten.swift new file mode 100644 index 0000000..e21e0c7 --- /dev/null +++ b/Sources/ConvertSwiftSDKCore/Rules/RuleAdapter+JSONSentinelFlatten.swift @@ -0,0 +1,195 @@ +// RuleAdapter+JSONSentinelFlatten.swift +// The JSON-sentinel flatten path (IOS-2, qs-03 mobile mutual-exclusion). Split into its OWN +// file purely to keep `RuleAdapter.swift` under SwiftLint's `file_length` / `type_body_length` +// gates — mirrors the `ProjectConfig+AudienceDecoding.swift` precedent (IOS-1), which split for +// the same reason rather than being an independent feature. +// +// A `bucketed_into_experience_key` leaf is an UNKNOWN `rule_type` discriminator to the generated +// schema, so it fails `Components.Schemas.RuleObjectAudience`'s typed decode +// (`unknownOneOfDiscriminator`) and the WHOLE audience degrades to a `JSONValue` sentinel +// (IOS-1 — `ProjectConfig.degradedAudienceSentinels`). `RuleAdapter.flatten(_ sentinelRuleTree:)` +// walks that raw JSON rule tree directly so a degraded audience's leaves — the stateful one AND +// any generic sibling in the SAME tree — still resolve, reusing (not forking) the OR/AND/OR_WHEN +// collapse and the shared `RuleAdapter.make(key:value:negated:matchType:)` / `degraded()` leaf +// builders (`RuleAdapter.swift`). +// +// Foundation-only — pure mapping over a decoded `JSONValue`; no platform framework, no state. + +import Foundation + +extension RuleAdapter { + + /// Rule-type discriminators whose match KEY is an explicit `key` member (`GenericKey`) rather + /// than `rule_type` itself — the JSON-walk parallel of the typed switch's + /// `condition(fromTextKeyValue:)` / `condition(fromNumericKeyValue:)` / + /// `condition(fromBoolKeyValue:)` routing (`RuleAdapter.swift`). + private static let keyValueRuleTypes: Set = [ + "generic_text_key_value", "generic_numeric_key_value", "generic_bool_key_value" + ] + + /// Named (non-key-value) `rule_type` discriminators the typed switch + /// `RuleAdapter.condition(fromAudienceLeaf:)` (`RuleAdapter.swift`) routes to a LIVE extractor. + /// This is the JSON-walk's coverage boundary and MUST match that switch's covered cases + /// exactly, so a `rule_type` the typed switch does NOT enumerate (e.g. the stateful + /// `bucketed_into_experience`, or any bd-d4p-deferred family) fails closed identically on both + /// paths, never a wrong-positive. `condition(fromAudienceLeaf:)` is the source of truth — keep + /// this set in sync with it by hand; it is not shared code because the typed switch's routing + /// itself is out of scope for this JSON-walk feature. + private static let namedFamilyRuleTypes: Set = [ + // text family — condition(fromText:) + "browser_version", "campaign", "city", "keyword", "medium", + "page_tag_category_id", "page_tag_category_name", "page_tag_custom_1", + "page_tag_custom_2", "page_tag_custom_3", "page_tag_custom_4", + "page_tag_customer_id", "page_tag_page_type", "page_tag_product_name", + "page_tag_product_sku", "query_string", "region", "source_name", "url", + "url_with_query", "user_agent", "visitor_id", + // country — condition(fromCountry:) + "country", + // numeric family — condition(fromNumeric:) + "avg_time_page", "days_since_last_visit", "page_tag_product_price", + "pages_visited_count", "visit_duration", "visits_count", + // bool family — condition(fromBool:); NOTE `bucketed_into_experience` is ALSO + // `GenericBoolMatchRule` but is deliberately NOT in this set, mirroring the typed switch's + // `fromBool` doc comment that it stays unrouted (falls through to `default: degraded()`). + "is_desktop", "is_mobile", "is_tablet", + // singleton families + "cookie", "language", "browser_name", "os" + ] + + /// Flattens a `rules` sub-tree captured as a raw `JSONValue` sentinel (the shape a degraded + /// audience's `rules` member has — see `ProjectConfig+AudienceDecoding.swift`) into the same + /// flat `[RuleGroup]` model the typed overloads in `RuleAdapter.swift` produce: one + /// `RuleGroup` per AND-block, whose `conditions` are that block's `OR_WHEN` leaves. + /// + /// - Parameter sentinelRuleTree: The `{"OR": [...]}` rules sub-tree, decoded as `JSONValue`. + /// - Returns: The flat outer-OR of AND-groups. A non-object root, or an absent/non-array + /// `OR` member, yields an empty array (fail-closed, same as the typed overloads). + static func flatten(_ sentinelRuleTree: JSONValue) -> [RuleGroup] { + guard + case let .object(rootPairs) = sentinelRuleTree, + let orBlocks = arrayValue(of: "OR", in: rootPairs) + else { + return [] + } + return orBlocks.map { andBlockValue in + let leaves = andBlockLeaves(andBlockValue) + return RuleGroup(conditions: leaves.map(condition(fromSentinelLeaf:))) + } + } + + /// Collects one AND-block's `OR_WHEN` leaves (flattening across every `AND` entry, mirroring + /// the typed overloads' `(andBlock.AND ?? []).flatMap { $0.OR_WHEN ?? [] }`). + private static func andBlockLeaves(_ andBlockValue: JSONValue) -> [JSONValue] { + guard + case let .object(andBlockPairs) = andBlockValue, + let andEntries = arrayValue(of: "AND", in: andBlockPairs) + else { + return [] + } + return andEntries.flatMap { andEntryValue -> [JSONValue] in + guard + case let .object(andEntryPairs) = andEntryValue, + let orWhenLeaves = arrayValue(of: "OR_WHEN", in: andEntryPairs) + else { + return [] + } + return orWhenLeaves + } + } + + /// Maps one raw JSON leaf to a flat ``RuleCondition``. A `bucketed_into_experience_key` + /// `rule_type` produces a condition carrying a non-nil ``StatefulRuleTarget`` (`value` -> + /// `targetExperienceKey`, `matching.negated` -> `negation`); every other `rule_type` reuses + /// the SAME `RuleAdapter.make(...)` builder the typed path's per-family extractors call, + /// keyed off either the explicit `key` member (the three key-value families) or `rule_type` + /// itself (every named family) — the JSON-walk parallel of the typed switches' routing. + private static func condition(fromSentinelLeaf leaf: JSONValue) -> RuleCondition { + guard case let .object(pairs) = leaf else { return degraded() } + let ruleType = stringValue(of: "rule_type", in: pairs) ?? "" + let matchingPairs = objectPairs(of: "matching", in: pairs) + let negated = matchingPairs.flatMap { boolValue(of: "negated", in: $0) } ?? false + let matchType = matchingPairs.flatMap { stringValue(of: "match_type", in: $0) } + let rawValue = pairs.first { $0.key == "value" }?.value + + if ruleType == "bucketed_into_experience_key" { + return RuleCondition( + key: ruleType, + matchType: "", + value: nil, + negation: negated, + statefulTarget: StatefulRuleTarget( + ruleType: ruleType, + targetExperienceKey: ruleValueString(rawValue) ?? "" + ) + ) + } + + if keyValueRuleTypes.contains(ruleType) { + let key = stringValue(of: "key", in: pairs) ?? "" + return make(key: key, value: ruleValueString(rawValue), negated: negated, matchType: matchType) + } + + // Coverage-boundary gate (code-review R1 / AC7 divergence probe): a `rule_type` NOT in the + // named-family allowlist above — e.g. `bucketed_into_experience`, or any bd-d4p-deferred + // family — is UNMAPPED in the typed switch too, so it must degrade fail-closed here as + // well, rather than routing through `make(...)` with the leaf's real matching/value. + guard namedFamilyRuleTypes.contains(ruleType) else { + return degraded() + } + + return make(key: ruleType, value: ruleValueString(rawValue), negated: negated, matchType: matchType) + } + + /// Converts a leaf's raw `value` member to the `String?` ``RuleCondition/value`` needs, + /// matching the typed path's per-type stringification (a JSON string passes through as-is; a + /// JSON bool/number stringifies via `String(_:)`, exactly as the typed numeric/bool + /// extractors' `.map { String($0) }` do). + private static func ruleValueString(_ value: JSONValue?) -> String? { + switch value { + case let .string(text)?: + return text + case let .bool(flag)?: + return String(flag) + case let .number(number)?: + return String(number) + default: + return nil + } + } + + /// The `String` value of the `name`-keyed member in a `JSONValue.object`'s pairs, or `nil` + /// when absent or not a JSON string. + private static func stringValue(of name: String, in pairs: [JSONValue.Pair]) -> String? { + guard case let .string(value)? = pairs.first(where: { $0.key == name })?.value else { + return nil + } + return value + } + + /// The `Bool` value of the `name`-keyed member in a `JSONValue.object`'s pairs, or `nil` when + /// absent or not a JSON bool. + private static func boolValue(of name: String, in pairs: [JSONValue.Pair]) -> Bool? { + guard case let .bool(value)? = pairs.first(where: { $0.key == name })?.value else { + return nil + } + return value + } + + /// The array-typed member `name` in a `JSONValue.object`'s pairs, or `nil` when absent or not + /// a JSON array. + private static func arrayValue(of name: String, in pairs: [JSONValue.Pair]) -> [JSONValue]? { + guard case let .array(value)? = pairs.first(where: { $0.key == name })?.value else { + return nil + } + return value + } + + /// The object-typed member `name`'s pairs in a `JSONValue.object`'s pairs, or `nil` when + /// absent or not a JSON object. + private static func objectPairs(of name: String, in pairs: [JSONValue.Pair]) -> [JSONValue.Pair]? { + guard case let .object(value)? = pairs.first(where: { $0.key == name })?.value else { + return nil + } + return value + } +} diff --git a/Sources/ConvertSwiftSDKCore/Rules/RuleAdapter.swift b/Sources/ConvertSwiftSDKCore/Rules/RuleAdapter.swift index 2e21cc5..6995ba2 100644 --- a/Sources/ConvertSwiftSDKCore/Rules/RuleAdapter.swift +++ b/Sources/ConvertSwiftSDKCore/Rules/RuleAdapter.swift @@ -318,8 +318,10 @@ internal enum RuleAdapter { /// Builds a ``RuleCondition`` from the four extracted fields, applying the documented defaults: /// an absent `match_type` becomes `""` (fail-closed in ``Comparisons``); an absent `negated` - /// becomes `false`. - private static func make( + /// becomes `false`. Internal (not `private`) so `RuleAdapter+JSONSentinelFlatten.swift`'s + /// JSON-sentinel leaf mapping (IOS-2, qs-03) can reuse the SAME builder the typed per-family + /// extractors above call, rather than forking the leaf field mapping. + static func make( key: String, value: String?, negated: Bool?, @@ -338,8 +340,9 @@ internal enum RuleAdapter { /// `matchType` is absent from ``Comparisons/comparators``, so the condition evaluates to false — /// never a wrong-positive. `key` is left empty because no attribute lookup can succeed once the /// operator is unmapped. Extending coverage means adding the leaf's `case` to the switches above, - /// NOT changing this fail-closed default. - private static func degraded() -> RuleCondition { + /// NOT changing this fail-closed default. Internal (not `private`) for the same + /// `RuleAdapter+JSONSentinelFlatten.swift` reuse reason as ``make(key:value:negated:matchType:)``. + static func degraded() -> RuleCondition { RuleCondition(key: "", matchType: "", value: nil, negation: false) } } diff --git a/Sources/ConvertSwiftSDKCore/Rules/RuleManager.swift b/Sources/ConvertSwiftSDKCore/Rules/RuleManager.swift index 7547d96..1a4989a 100644 --- a/Sources/ConvertSwiftSDKCore/Rules/RuleManager.swift +++ b/Sources/ConvertSwiftSDKCore/Rules/RuleManager.swift @@ -1,11 +1,15 @@ // RuleManager.swift // OR-of-AND rule-set evaluator for audience / location matching (Epic 3 / Story 3). // -// PARITY NOTE — JavaScript SDK is ground truth. The boolean structure mirrors the LIVE -// `javascript-sdk/packages/rules/src/rule-manager.ts` (verified). The live JS is three -// levels (OR → AND → OR_WHEN → item); this story collapses it to a FLAT two-level model -// (`[RuleGroup]` outer-OR of `RuleGroup { conditions }` inner-AND) per the story tasks — -// Story 3.4 adapts the generated 3-level `RuleObjectAudience` graph into this flat model. +// PARITY NOTE — the boolean structure (OR-of-AND over a flat two-level `[RuleGroup]` outer-OR +// of `RuleGroup { conditions }` inner-AND) is intended to mirror the LIVE JS +// `javascript-sdk/packages/rules/src/rule-manager.ts`'s three-level OR → AND → OR_WHEN → item +// walk collapsed per the Story 3.4 flattening. CAVEAT (recorded here rather than claimed as +// blanket-verified): the collapse is confirmed bit-identical to the live JS ONLY for the +// single-leaf `OR_WHEN` shape every fixture in this suite exercises; `RuleAdapter`'s +// AND(OR(leaves)) → AND(all leaves) collapse for a MULTI-leaf `OR_WHEN` block has not been +// checked against JS's OR_WHEN-is-an-OR semantics and is tracked as a known, separately-filed +// gap — beads issue `ai-driven-product-dev-iefd`. Out of scope here; not fixed by this file. // // FAIL-CLOSED (AC3): both empty-collection cases return `false` AND log a WARN — an empty // outer rule set and an empty AND group. Eligibility is NEVER vacuous-true. @@ -15,6 +19,16 @@ // never short-circuits on a missing key, because `exists` / `doesNotExist` rely on nil // reaching the comparator to compute presence. // +// GENERIC-ONLY EVALUATOR (M2, iOS mutual-exclusion qs-04 re-architecture): `evaluate` no +// longer accepts a `resolvingBucketedIntoExperienceKey` resolver — a whole-audience +// `bucketed_into_experience_key` exclusion rule is now detected and resolved OUTSIDE this type, +// at the `ExperienceManager` audience layer, via the dedicated ``BucketingExclusion`` seam +// (mirrors JS's `_isBucketingExclusionRule` / `_resolveBucketingExclusion`, which likewise never +// route through the generic `isRuleMatched` engine for an exclusion audience). This evaluator +// therefore only ever sees generic (non-stateful) conditions again — see the sibling +// `Tests/ConvertSwiftSDKCoreTests/Rules/MutualExclusionRuleManagerTests.swift` header for the +// full rework rationale. +// // Foundation-only: a stateless `struct` whose only stored property is a `Sendable` ``Logger`` // is itself `Sendable` — no actor isolation needed. @@ -72,10 +86,10 @@ internal struct RuleManager { } } - /// Evaluates one leaf condition by dispatching to ``Comparisons``. The attribute lookup is - /// an optional (nil when the key is absent); that optional flows straight through for EVERY - /// operator — there is NO short-circuit on a missing key (AC2), because exists/doesNotExist - /// compute presence from the nil itself. + /// Evaluates one leaf condition against `attributes`. The lookup is an optional (`nil` + /// when the key is absent) that flows straight through for EVERY operator — there is NO + /// short-circuit on a missing key (AC2), because exists/doesNotExist compute presence from + /// the nil itself. private func evaluate(condition: RuleCondition, against attributes: [String: String]) -> Bool { let value = attributes[condition.key] return Comparisons.evaluate( diff --git a/Tests/ConvertSwiftSDKCoreTests/Bucketing/AnchoredBucketingGateAndBoundaryTests.swift b/Tests/ConvertSwiftSDKCoreTests/Bucketing/AnchoredBucketingGateAndBoundaryTests.swift new file mode 100644 index 0000000..68ec154 --- /dev/null +++ b/Tests/ConvertSwiftSDKCoreTests/Bucketing/AnchoredBucketingGateAndBoundaryTests.swift @@ -0,0 +1,338 @@ +// Tests/ConvertSwiftSDKCoreTests/Bucketing/AnchoredBucketingGateAndBoundaryTests.swift +// Anchored bucketing layout test suite for qs-01 (cross-SDK bucketing contract v12). +// Spec of record: `2026-06-09-convert-ios-sdk/qs-01-anchored-bucketing-layout.md`. +// +// Sibling of `AnchoredBucketingParityTests.swift` (the 59-vector golden-fixture sweep, AC7). +// THIS file covers every AC the fixture sweep alone doesn't isolate: +// * AC1 — the `version` gate (>11 anchored, <=11/missing packed). +// * AC4 — stopped-arm zero-width (weight preserved, anchors unmoved) + explicit `ta:0` != 100. +// * AC5 — NaN/absent-ta defaults to 100.0 weight; totalWeight<=0 -> nil; anchor/width boundary +// inclusivity (`value == anchor` IN, `value == anchor + width` OUT). +// * AC6 — the packed pass is not just "produces the same answer" but DELEGATES verbatim. +// * AC8 — a sticky decision wins over both layouts (a structural lock, not new behavior). +// * AC9 — a successful anchored bucket preserves the unchanged `.bucketing` event shape. +// +// ── File-scope data types (not nested in the `@Suite` struct) ───────────────────────────── +// `BoundaryVariationSpec`/`BoundaryVector` and the boundary-vector arrays live at file scope so +// the `@Suite` struct's body stays under SwiftLint's `type_body_length` gate, and so the whole +// file stays under `file_length` — splitting data from behavior, not duplicating either. +// +// ── SonarQube `new_duplicated_lines_density` discipline ─────────────────────────────────── +// ONE parameterized `@Test(arguments:)` drives the AC1 gate sweep, a SECOND drives the full +// AC4/AC5 boundary sweep — mirroring `BucketingManagerTests.selectBucketAccumulateFirstWins` for +// the packed selector. Every manager goes through `makeManager`. + +import Foundation +import Testing +@testable import ConvertSwiftSDKCore + +/// One hand-built ANCHORED scenario: a variation spec plus the `value` to select at and the +/// expected result — a named struct (not a tuple) so `large_tuple` stays satisfied. +struct BoundaryVariationSpec: Sendable { + let id: String + let trafficAllocation: Double? + let status: Components.Schemas.VariationStatuses? +} + +/// One direct `AnchoredBucketing.selectBucket` boundary vector — hand-computed from the spec's +/// normative pseudocode (NOT derived from the golden-vector fixture, which already covers the +/// end-to-end hash-driven path; these isolate the pure per-value selection math at controlled +/// `value`s the fixture cannot target directly). +struct AnchoredBoundaryVector: Sendable { + let description: String + let variations: [BoundaryVariationSpec] + let value: Int + let expected: String? +} + +/// Two arms, 30/70 split, both `running`. `totalWeight = 100`; A covers `[0,3000)`, B covers +/// `[3000,10000)` — exercises AC5's `value == anchor` (IN) / `value == anchor + width` (OUT) +/// boundary at the shared edge (`3000`), where A's upper bound and B's anchor coincide. +private let thirtySeventyBoundaries: [AnchoredBoundaryVector] = [ + AnchoredBoundaryVector( + description: "AC5 — value == A's anchor (0) is IN", + variations: [ + BoundaryVariationSpec(id: "A", trafficAllocation: 30, status: .running), + BoundaryVariationSpec(id: "B", trafficAllocation: 70, status: .running) + ], + value: 0, + expected: "A" + ), + AnchoredBoundaryVector( + description: "AC5 — value just below A's anchor + width (2999) is still IN for A", + variations: [ + BoundaryVariationSpec(id: "A", trafficAllocation: 30, status: .running), + BoundaryVariationSpec(id: "B", trafficAllocation: 70, status: .running) + ], + value: 2_999, + expected: "A" + ), + AnchoredBoundaryVector( + description: "AC5 — value == A's anchor + width (3000) is OUT for A and IN for B " + + "(B's anchor coincides at 3000)", + variations: [ + BoundaryVariationSpec(id: "A", trafficAllocation: 30, status: .running), + BoundaryVariationSpec(id: "B", trafficAllocation: 70, status: .running) + ], + value: 3_000, + expected: "B" + ), + AnchoredBoundaryVector( + description: "AC5 — value at the top edge of the bucket space (9999) is IN for B", + variations: [ + BoundaryVariationSpec(id: "A", trafficAllocation: 30, status: .running), + BoundaryVariationSpec(id: "B", trafficAllocation: 70, status: .running) + ], + value: 9_999, + expected: "B" + ) +] + +/// Three arms 30/30/40; B is `stopped` (ta preserved). AC4: B keeps its WEIGHT (C's anchor still +/// lands at 6000, as if B were active) but gets ZERO width — so `[3000,6000)` is a dead zone +/// (not-bucketed), never reassigned to A or C. +private let stoppedArmBoundaries: [AnchoredBoundaryVector] = [ + AnchoredBoundaryVector( + description: "AC4 — value just below the stopped arm's anchor (2999) is IN for A", + variations: [ + BoundaryVariationSpec(id: "A", trafficAllocation: 30, status: .running), + BoundaryVariationSpec(id: "B", trafficAllocation: 30, status: .stopped), + BoundaryVariationSpec(id: "C", trafficAllocation: 40, status: .running) + ], + value: 2_999, + expected: "A" + ), + AnchoredBoundaryVector( + description: "AC4 — value at the stopped arm's anchor (3000) falls in its zero-width " + + "dead zone -> not bucketed; the anchor did NOT move to close the gap", + variations: [ + BoundaryVariationSpec(id: "A", trafficAllocation: 30, status: .running), + BoundaryVariationSpec(id: "B", trafficAllocation: 30, status: .stopped), + BoundaryVariationSpec(id: "C", trafficAllocation: 40, status: .running) + ], + value: 3_000, + expected: nil + ), + AnchoredBoundaryVector( + description: "AC4 — the dead zone persists right up to the next active arm's anchor (5999)", + variations: [ + BoundaryVariationSpec(id: "A", trafficAllocation: 30, status: .running), + BoundaryVariationSpec(id: "B", trafficAllocation: 30, status: .stopped), + BoundaryVariationSpec(id: "C", trafficAllocation: 40, status: .running) + ], + value: 5_999, + expected: nil + ), + AnchoredBoundaryVector( + description: "AC4 — C's anchor (6000) starts exactly where B's PRESERVED weight ends -> IN", + variations: [ + BoundaryVariationSpec(id: "A", trafficAllocation: 30, status: .running), + BoundaryVariationSpec(id: "B", trafficAllocation: 30, status: .stopped), + BoundaryVariationSpec(id: "C", trafficAllocation: 40, status: .running) + ], + value: 6_000, + expected: "C" + ) +] + +/// AC4 (explicit `ta: 0`, never `stopped`) + AC5 (NaN/absent default, and totalWeight <= 0). +private let defaultAndZeroWeightBoundaries: [AnchoredBoundaryVector] = [ + AnchoredBoundaryVector( + description: "AC4 — explicit ta:0 (status running, NOT stopped) is ZERO width, never " + + "100: the whole space falls to B", + variations: [ + BoundaryVariationSpec(id: "A", trafficAllocation: 0, status: .running), + BoundaryVariationSpec(id: "B", trafficAllocation: 100, status: .running) + ], + value: 0, + expected: "B" + ), + AnchoredBoundaryVector( + description: "AC5 — NaN/absent traffic_allocation defaults to 100.0 weight: a sole " + + "omitted-ta arm covers the whole space", + variations: [ + BoundaryVariationSpec(id: "SOLE", trafficAllocation: nil, status: .running) + ], + value: 9_999, + expected: "SOLE" + ), + AnchoredBoundaryVector( + description: "AC5 — totalWeight <= 0 (all-zero arms) is not-bucketed regardless of value", + variations: [ + BoundaryVariationSpec(id: "A", trafficAllocation: 0, status: .running), + BoundaryVariationSpec(id: "B", trafficAllocation: 0, status: .stopped) + ], + value: 0, + expected: nil + ) +] + +/// The full AC4/AC5 boundary sweep — every scenario above, in one flat array. +private let anchoredBoundaryVectors: [AnchoredBoundaryVector] = + thirtySeventyBoundaries + stoppedArmBoundaries + defaultAndZeroWeightBoundaries + +@Suite("AnchoredBucketingGateAndBoundary") +struct AnchoredBucketingGateAndBoundaryTests { + + // MARK: - Shared builders (SonarQube 3% new-duplicated-lines gate) + + /// Builds the subject with a recording event sink and a no-op logger — every test that needs + /// a manager goes through this so construction is declared exactly once. + private func makeManager(eventSink: MockEventSink = MockEventSink()) -> BucketingManager { + BucketingManager(eventSink: eventSink, logger: MockLogger()) + } + + /// A single-variation, single-arm, sole-100%-allocation `running` experience at the given + /// `version` — used by both the AC1 gate-branching sweep and the AC9 event-shape test. A + /// 100%-allocation arm is guaranteed to bucket EVERY visitor under both layouts (packed: + /// `[0,10000)`; anchored: anchor `0`, width `10000`), so it isolates "did the gate route + /// somewhere that buckets" from any hash/weight-math edge case. + private func makeSingleFullAllocationExperience(version: Double?) -> Components.Schemas.ConfigExperience { + Components.Schemas.ConfigExperience( + id: "gate-exp", + version: version, + variations: [ + Components.Schemas.ExperienceVariationConfig( + id: "only", traffic_allocation: 100, status: .running + ) + ] + ) + } + + /// Builds the `ExperienceVariationConfig` array a boundary vector describes. + private func makeVariations(_ specs: [BoundaryVariationSpec]) -> [Components.Schemas.ExperienceVariationConfig] { + specs.map { spec in + Components.Schemas.ExperienceVariationConfig( + id: spec.id, traffic_allocation: spec.trafficAllocation, status: spec.status + ) + } + } + + // MARK: - AC1 — gate branching + + /// AC1: `version > 11` routes to ANCHORED; `version <= 11` or missing routes to PACKED. Every + /// case uses the sole 100%-allocation arm (see `makeSingleFullAllocationExperience`), whose + /// correct/final answer is `"only"` under EITHER layout — so a non-`"only"` result proves the + /// gate routed somewhere broken. The v12 case resolves through the real + /// `AnchoredBucketing.selectBucket`; the packed cases resolve through AC6's verbatim + /// delegation to the existing `bucket(...)`. + @Test( + "AC1 — version gate: >11 routes to ANCHORED, <=11/missing routes to PACKED", + arguments: [ + (version: 12.0, label: "v12 (>11) -> anchored"), + (version: 11.0, label: "v11 -> packed (the inert-on-ship production stamp)"), + (version: 5.0, label: "v5 (<11) -> packed"), + (version: nil, label: "missing version -> packed") + ] as [(version: Double?, label: String)] + ) + func gateBranchesOnVersion(version: Double?, label: String) async { + let experience = makeSingleFullAllocationExperience(version: version) + let manager = makeManager() + let result = await manager.bucketVersionGated( + visitorId: "any-visitor", experience: experience, enableTracking: false + ) + #expect(result?.id == "only", Comment(rawValue: label)) + } + + // MARK: - AC4 / AC5 — anchored selector boundaries, defaults, and stops + + /// AC4/AC5, driven directly against the pure `AnchoredBucketing.selectBucket` selector (no + /// hash, no `BucketingManager`) at hand-picked `value`s the golden-vector fixture cannot + /// target precisely. Every case — whether `expected` is a real variation id or `nil` — + /// resolves through the real anchored selector. + @Test( + "AC4/AC5 — anchored selector boundaries, defaults, and stops", + arguments: anchoredBoundaryVectors + ) + func anchoredBoundaries(_ vector: AnchoredBoundaryVector) { + let selected = AnchoredBucketing.selectBucket( + variations: makeVariations(vector.variations), value: vector.value + ) + #expect(selected == vector.expected, Comment(rawValue: vector.description)) + } + + // MARK: - AC6 — packed regression lock (delegation, not just outcome) + + /// AC6: for `version <= 11`, `bucketVersionGated` must delegate VERBATIM to the existing + /// `bucket(...)` — not merely produce the same answer by coincidence. Calling both with the + /// same experience/visitor and asserting identical results locks the delegation itself. + @Test("AC6 — v11 delegates verbatim: bucketVersionGated matches bucket() bit-for-bit") + func packedRegressionLockDelegatesVerbatim() async { + let experience = Components.Schemas.ConfigExperience( + id: "pack-exp", + version: 11, + variations: [ + Components.Schemas.ExperienceVariationConfig(id: "a", traffic_allocation: 50, status: .running), + Components.Schemas.ExperienceVariationConfig(id: "b", traffic_allocation: 50, status: .running) + ] + ) + let manager = makeManager() + let direct = await manager.bucket(visitorId: "visitor-x", experience: experience, enableTracking: false) + let gated = await manager.bucketVersionGated( + visitorId: "visitor-x", experience: experience, enableTracking: false + ) + #expect(gated?.id == direct?.id, "v11 must route bucketVersionGated -> bucket() untouched") + } + + // MARK: - AC8 — sticky decision wins over both layouts + + /// AC8: a pre-seeded sticky decision short-circuits `ExperienceManager.selectVariation` at + /// step 2, strictly BEFORE step 5's bucket call — so it wins regardless of which layout step 5 + /// would otherwise have run. This is a STRUCTURAL guarantee already true today + /// (`selectVariation` never even inspects `experience.version` before the sticky check), and + /// stays true once Phase 2 rewires step 5 to call `bucketVersionGated` instead of `bucket()` + /// directly — the sticky check sits strictly earlier in the pipeline either way. Passes today; + /// it is a lock, not a new-behavior assertion. + @Test("AC8 — a stored (sticky) decision wins over both bucketing layouts") + func stickyDecisionWinsOverBothLayouts() async throws { + let config = try ProjectConfigFixtures.singleExperienceConfig( + experienceId: "exp-1", key: "sticky-exp", variationId: "sticky-var" + ) + let store = DecisionStore(logger: MockLogger(), fileStore: MockFileStore()) + await store.saveDecision(variationId: "sticky-var", experienceId: "exp-1", storeKey: "a-p-v1") + let sink = MockEventSink() + let subject = ExperienceManager( + ruleManager: RuleManager(logger: MockLogger()), + bucketingManager: BucketingManager(eventSink: sink, logger: MockLogger()), + decisionStore: store, + eventBus: EventBus(), + logger: MockLogger() + ) + + let variation = await subject.selectVariation( + forKey: "sticky-exp", + in: config, + visitorId: "v1", + accountId: "a", + projectId: "p", + attributes: [:], + locationProperties: [:], + enableTracking: true + ) + + #expect(variation?.id == "sticky-var") + let events = await sink.recordedEvents() + #expect(events.isEmpty, "a sticky hit must never reach ANY bucketing pass (packed or anchored)") + } + + // MARK: - AC9 — no event/API drift + + /// AC9: a successful ANCHORED bucket must enqueue exactly ONE `.bucketing`-tagged event — + /// same shape as the packed pass. The real selector resolves the variation, and + /// `bucketVersionGated`'s result-mapping/enqueue plumbing emits the event on that success. + @Test("AC9 — a successful anchored bucket enqueues exactly one unchanged-shape bucketing event") + func anchoredBucketPreservesEventShape() async { + let sink = MockEventSink() + let experience = makeSingleFullAllocationExperience(version: 12) + let manager = makeManager(eventSink: sink) + + let variation = await manager.bucketVersionGated( + visitorId: "any-visitor", experience: experience, enableTracking: true + ) + + #expect(variation?.id == "only") + let events = await sink.recordedEvents() + #expect(events.count == 1) + #expect(events.first?.eventType == "bucketing") + } +} diff --git a/Tests/ConvertSwiftSDKCoreTests/Bucketing/AnchoredBucketingParityTests.swift b/Tests/ConvertSwiftSDKCoreTests/Bucketing/AnchoredBucketingParityTests.swift new file mode 100644 index 0000000..cbc3e70 --- /dev/null +++ b/Tests/ConvertSwiftSDKCoreTests/Bucketing/AnchoredBucketingParityTests.swift @@ -0,0 +1,136 @@ +// Tests/ConvertSwiftSDKCoreTests/Bucketing/AnchoredBucketingParityTests.swift +// Cross-SDK anchored/packed bucketing parity suite for qs-01 (cross-SDK bucketing contract v12). +// Spec of record: `2026-06-09-convert-ios-sdk/qs-01-anchored-bucketing-layout.md`. +// +// ── Why a NEW file (not an extension of HashParityTests.swift) ─────────────────────────── +// `HashParityTests.swift` drives `MurmurHash3` + `BucketingManager.selectBucket` (the PACKED +// selector) directly over `hash-parity-vectors.json` (hash+selectBucket only, no version gate, no +// ConfigExperience). `cross-sdk-bucketing-vectors.json` is structurally different: it carries a +// full `{experienceId, visitorId, version, variations:[{id, traffic_allocation, status?}]}` shape +// and must be driven end-to-end through the version-gated entry point +// (`BucketingManager.bucketVersionGated`), asserting the resolved variation id/nil. Different +// fixture shape, different subject under test → a separate file. The AC1/AC4/AC5/AC6/AC8/AC9 +// focused tests (not derivable from this fixture alone) live in the sibling +// `AnchoredBucketingGateAndBoundaryTests.swift` — kept out of THIS file to stay under SwiftLint's +// `file_length`/`type_body_length` gates. +// +// ── Decodable types at FILE scope, not nested in the `@Suite` struct ────────────────────── +// `VariationVector`/`Vector` sit at file scope (not nested inside `AnchoredBucketingParityTests`) +// so `VariationVector`'s `CodingKeys` enum is only ONE level of nesting deep — nesting them inside +// the suite struct as well would put `CodingKeys` two levels deep, tripping SwiftLint's `nesting` +// rule (max 1 level). +// +// ── Parity coverage ─────────────────────────────────────────────────────────────────────── +// All 59 golden vectors resolve through `bucketVersionGated`: v12 (anchored, `version > 11`) +// vectors route to `AnchoredBucketing.selectBucket`; v11 (packed) vectors delegate verbatim to +// the existing `bucket(...)` (AC6). The packed `eligible` walk (`BucketingManager.bucket`, step 5) +// defaults an omitted/NaN `traffic_allocation` to 100.0, matching the anchored pass and the JS +// reference's `data-manager.ts:575` builder — see `qs-01-decision-log.md` for the write-up. +// +// ── SonarQube `new_duplicated_lines_density` discipline ─────────────────────────────────── +// ONE parameterized `@Test(arguments:)` drives all 59 golden vectors — no per-vector duplication. + +import Foundation +import Testing +@testable import ConvertSwiftSDKCore + +/// One variation entry inside a golden vector's `variations` array. `trafficAllocation` mirrors +/// the wire's snake_case `traffic_allocation` via explicit `CodingKeys` (kept camelCase in Swift, +/// unlike the generated schema's own snake_case property, to stay SwiftLint-clean in a +/// non-generated file). `status` is decoded as the raw wire String and mapped onto +/// `Components.Schemas.VariationStatuses` when building a real config. +struct AnchoredVariationVector: Decodable, Sendable { + let id: String + let trafficAllocation: Double? + let status: String? + + enum CodingKeys: String, CodingKey { + case id + case trafficAllocation = "traffic_allocation" + case status + } +} + +/// One golden vector, decoded straight from `cross-sdk-bucketing-vectors.json`. `expected` is the +/// resolved variation id, or `nil` for a not-bucketed vector. +struct AnchoredBucketingVector: Decodable, Sendable { + let description: String + let experienceId: String + let visitorId: String + let version: Double + let variations: [AnchoredVariationVector] + let expected: String? +} + +@Suite("AnchoredBucketingParity") +struct AnchoredBucketingParityTests { + + /// The decoded golden vectors, loaded from the `Fixtures/` resource directory (same bundling + /// mechanism as `HashParityTests.vectors` — `resources: [.copy("Fixtures")]` on the + /// `ConvertSwiftSDKCoreTests` target in `Package.swift`). Fully defensive load (`try?` + /// throughout, `?? []` on failure): the lint gate forbids `!`/`try!`/`fatalError` + /// (`force_unwrapping`), and a static `let` initializer cannot `throw`. The `fixtureLoaded` + /// guard test below converts a failed/partial load into a LOUD failure instead of a + /// vacuously-passing empty parameterized suite. + static let vectors: [AnchoredBucketingVector] = { + guard + let url = Bundle.module.url( + forResource: "cross-sdk-bucketing-vectors", + withExtension: "json", + subdirectory: "Fixtures" + ), + let data = try? Data(contentsOf: url), + let decoded = try? JSONDecoder().decode([AnchoredBucketingVector].self, from: data) + else { + return [] + } + return decoded + }() + + /// Guard test: the fixture loaded and carries the full committed 59-vector set (AC7). If the + /// bundled resource is missing or fails to decode, `vectors` is empty and the parameterized + /// parity test below would pass vacuously — this asserts the count so that case fails LOUDLY. + @Test("fixture loaded — all 59 committed cross-SDK vectors decode") + func fixtureLoaded() { + #expect( + Self.vectors.count >= 59, + "expected >= 59 cross-SDK vectors, loaded \(Self.vectors.count) — fixture missing or failed to decode" + ) + } + + /// Builds a `Components.Schemas.ConfigExperience` from one golden vector, preserving config + /// order and passing EVERY variation through unfiltered (active and inactive, with or without + /// `traffic_allocation`) — the anchored pass interprets activity itself; only the packed + /// `eligible` walk pre-filters. + private func makeExperience(from vector: AnchoredBucketingVector) -> Components.Schemas.ConfigExperience { + let variations = vector.variations.map { entry in + Components.Schemas.ExperienceVariationConfig( + id: entry.id, + traffic_allocation: entry.trafficAllocation, + status: entry.status.flatMap(Components.Schemas.VariationStatuses.init(rawValue:)) + ) + } + return Components.Schemas.ConfigExperience( + id: vector.experienceId, + version: vector.version, + variations: variations + ) + } + + /// THE parity assertion (AC7). For each vector: build the experience, run it through + /// `bucketVersionGated` (the version-gated entry point qs-01 introduces), and assert the + /// resolved variation id — or `nil` for a not-bucketed vector — matches `expected`. One body + /// covers all 59 vectors (no per-vector duplication). `enableTracking: false` — this suite + /// asserts SELECTION, not the enqueue (that is AC9's job, isolated in the sibling file). + @Test("cross-SDK anchored/packed parity vector (AC7)", arguments: vectors) + func parity(_ vector: AnchoredBucketingVector) async { + let experience = makeExperience(from: vector) + let manager = BucketingManager(eventSink: MockEventSink(), logger: MockLogger()) + let result = await manager.bucketVersionGated( + visitorId: vector.visitorId, experience: experience, enableTracking: false + ) + let message = "\(vector.description): got \(String(describing: result?.id)), " + + "expected \(String(describing: vector.expected))" + #expect(result?.id == vector.expected, Comment(rawValue: message)) + } +} diff --git a/Tests/ConvertSwiftSDKCoreTests/Bucketing/BucketingManagerTests.swift b/Tests/ConvertSwiftSDKCoreTests/Bucketing/BucketingManagerTests.swift index 4d4b133..bfcdafb 100644 --- a/Tests/ConvertSwiftSDKCoreTests/Bucketing/BucketingManagerTests.swift +++ b/Tests/ConvertSwiftSDKCoreTests/Bucketing/BucketingManagerTests.swift @@ -29,6 +29,14 @@ struct BucketingManagerTests { let id: String let key: String let alloc: Double + let status: Components.Schemas.VariationStatuses? + + init(id: String, key: String, alloc: Double, status: Components.Schemas.VariationStatuses? = nil) { + self.id = id + self.key = key + self.alloc = alloc + self.status = status + } } /// The fields extracted from an encoded bucketing entry — a named struct (not a 3-member @@ -62,7 +70,8 @@ struct BucketingManagerTests { Components.Schemas.ExperienceVariationConfig( id: variation.id, key: variation.key, - traffic_allocation: variation.alloc + traffic_allocation: variation.alloc, + status: variation.status ) } return Components.Schemas.ConfigExperience(id: id, key: key, variations: configs) @@ -180,6 +189,41 @@ struct BucketingManagerTests { #expect(variation?.id == expectedVariationId) } + // MARK: - JS parity regression: a STOPPED variation is excluded from packed eligibility + + /// JS parity regression: `_buildPackedBuckets` filters on `status === RUNNING` — a STOPPED + /// variation must never get a bucket band, even carrying a nonzero `traffic_allocation`. Reuses + /// the SAME experience id / visitor ids / hash-derived bucket values already pinned by + /// ``fiftyFiftySplitBucketsAcrossTheSpace`` (`visitor-a` → bucket 3774, `visitor-c` → bucket + /// 7328), but with `varB` now `stopped` instead of `running`: `varA`'s `[0,5000)` band is + /// UNCHANGED (still selected for `visitor-a`), while `varB`'s would-be `[5000,10000)` band is + /// dropped entirely from `eligible` — `selectBucket`'s accumulated weight tops out at 5000, so + /// `visitor-c`'s bucket value (7328, in the now-uncovered tail) selects NOTHING rather than the + /// stopped arm. + @Test( + "JS parity — a STOPPED variation with nonzero traffic_allocation is never selected", + arguments: [ + (visitorId: "visitor-a", expectedVariationId: "varA"), + (visitorId: "visitor-c", expectedVariationId: nil) + ] as [(visitorId: String, expectedVariationId: String?)] + ) + func stoppedVariationExcludedFromPackedEligibility( + visitorId: String, + expectedVariationId: String? + ) async { + let experience = makeExperience( + id: "100334665", + key: "exp-key", + variations: [ + VariationSpec(id: "varA", key: "varA-key", alloc: 50, status: .running), + VariationSpec(id: "varB", key: "varB-key", alloc: 50, status: .stopped) + ] + ) + let manager = makeBucketingManager() + let variation = await manager.bucket(visitorId: visitorId, experience: experience) + #expect(variation?.id == expectedVariationId) + } + // MARK: - AC11 / AC12: enqueue exactly one bucketing event when tracking is enabled /// AC11/AC12: a successful bucket with `enableTracking: true` enqueues exactly one entry, diff --git a/Tests/ConvertSwiftSDKCoreTests/Data/ProjectConfigAudienceDegradeTests.swift b/Tests/ConvertSwiftSDKCoreTests/Data/ProjectConfigAudienceDegradeTests.swift new file mode 100644 index 0000000..3532027 --- /dev/null +++ b/Tests/ConvertSwiftSDKCoreTests/Data/ProjectConfigAudienceDegradeTests.swift @@ -0,0 +1,338 @@ +// Tests/ConvertSwiftSDKCoreTests/Data/ProjectConfigAudienceDegradeTests.swift +// +// RED-phase contract for IOS-1 (M1 decode-survival seam, iOS mutual-exclusion qs-03): a NEW +// audience rule leaf `bucketed_into_experience_key` is absent from the generated +// `RuleElementAudience` oneOf, so its decoder's `default:` case throws +// `DecodingError.unknownOneOfDiscriminator` (Generated/ConfigSchemas.swift:3399-3404, discriminator +// key `rule_type`, confirmed absent from `discriminator-manifest.json`'s `RuleElementAudience` entry +// alongside the sibling KNOWN case `bucketed_into_experience`). +// +// TODAY, `ProjectConfig.init(from:)` decodes `audiences` via a WHOLE-ARRAY `try?` +// (`ProjectConfig.swift:155-158`): +// +// audiences = try? container.decodeIfPresent([Components.Schemas.ConfigAudience].self, forKey: .audiences) +// +// So a SINGLE audience carrying the new leaf makes the ENTIRE array decode throw +// `unknownOneOfDiscriminator`; the `try?` converts that to `audiences == nil`; and +// `ExperienceManager.audiencePasses` (`ExperienceManager.swift:329-341`) treats an empty/nil +// audience set as UNRESTRICTED and returns `true` — a PROJECT-WIDE fail-OPEN regression the moment +// ANY audience anywhere in the config uses the new rule type. +// +// The eventual GREEN fix (NOT this task) is a hand-authored PER-AUDIENCE degrading decode in +// `ProjectConfig.swift`, mirroring the existing `DegradingExperience` per-element loop +// (`ProjectConfig.swift:136-154`, `:338-359` — the "LOOP-TERMINATION INVARIANT" comment) so a +// bad/unknown audience degrades out ALONE (siblings survive, array never nulled), and +// sentinel-captures the unknown leaf's raw JSON via the ALREADY-DEFINED-BUT-UNWIRED +// `RuleElementAudienceOrSentinel` typealias (`Generated/PolymorphicSentinels.swift:250-252` — grep +// confirms ZERO references to it anywhere in `Sources/` today) so a later task (IOS-2) can read the +// leaf's `rule_type` / `value` / `negated`. +// +// ── Scope of THIS suite ────────────────────────────────────────────────────────────────────── +// `ProjectConfig` decode ONLY. `ExperienceManager.audiencePasses`'s fail-open consequence is cited +// above for context/motivation but is NOT exercised here (out of scope for IOS-1). +// +// ── Why this suite could NOT pin the full "sentinel exposes rule_type/value/negated" contract +// against a NEW `ProjectConfig` accessor ────────────────────────────────────────────────────── +// The GREEN-phase accessor shape for reading a preserved audience's captured sentinel does not +// exist yet, and no story artifact fixes its exact shape (IOS-2/RuleAdapter is the future +// consumer — see `work/2026-07-15-ios-sdk-mutual-exclusion/workflow-state.yaml` lines 197-201). +// Referencing an invented symbol here would fail this file to COMPILE, and because +// `ConvertSwiftSDKCoreTests` is a single SPM module, that would break `swift test`/`swift build` +// for EVERY suite in the target, not just this one — the dispatch's "Verify RED" instructions +// explicitly rule out a compile-error RED. This suite therefore proves the two halves of the +// "preserved-with-sentinel" contract SEPARATELY, using ONLY symbols that exist in Sources/ today: +// (1) `audienceWithUnknownLeafIsPreservedNotDropped` — the FAILING half: `ProjectConfig`'s own +// `audience(id:)` must retrieve the bad audience by id (fails today — nil). +// (2) `sentinelWrappedAudienceAlreadyPreservesUnknownLeafFidelity` — an EVIDENCE test (passes +// today by design): proves the exact building block IOS-1's plan calls for +// (`SentinelWrapped`/`JSONValue`, already public, already correct) round-trips the unknown +// leaf's `rule_type`/`value`/`negated` with full fidelity when applied directly to an +// audience — i.e., the mechanism GREEN needs is ready and correct; only the WIRING into +// `ProjectConfig`'s own per-audience decode (test (1)) is the actual defect. +// This split is a documented ASSUMPTION/DECISION — see the sibling planning repo's decision log +// (`work/2026-07-15-ios-sdk-mutual-exclusion/decision-log.md`, "Implementation-level decisions"). +import Foundation +import Testing +@testable import ConvertSwiftSDKCore + +@Suite("ProjectConfig audience decode-survival (IOS-1 RED)") +struct ProjectConfigAudienceDegradeTests { + // MARK: - Shared fixtures + // (DRY: one envelope helper — reused from `ProjectConfigFixtures.makeConfig` — plus ONE + // leaf-parameterized audience builder, so no test body repeats a ≥10-line JSON literal. + // SonarQube `new_duplicated_lines_density` 3% guard; CPD is token-based, so reuse — not + // renaming — is what keeps the diff under the threshold.) + + /// A KNOWN rule leaf (`country == "US"`) — the exact shape `ProjectConfigFixtures.audienceJSON` + /// and `RuleAdapterTests` already prove decodes end-to-end. Used as the "everything is fine" + /// control so a test can prove ONE sibling in the array is the sole cause of a degrade. + static let knownLeafJSON = #"{"rule_type":"country","value":"US","matching":{"match_type":"equals"}}"# + + /// The NEW, unrecognised rule leaf under test: `bucketed_into_experience_key`, absent from the + /// generated `RuleElementAudience` oneOf (confirmed against `ConfigSchemas.swift:3294-3398` and + /// `discriminator-manifest.json`). `value` carries the target experience KEY (`"exp-a"`); the + /// discriminator has NO parity with the existing KNOWN `"bucketed_into_experience"` case. + static let unknownLeafJSON = #"{"rule_type":"bucketed_into_experience_key","value":"exp-a","negated":false}"# + + /// Builds ONE `ConfigAudience` JSON object whose `rules` graph is the fixed + /// `OR -> AND -> OR_WHEN` envelope (verified against `RuleObjectAudience`, + /// `ConfigSchemas.swift:3613-3667`) wrapping a single CALLER-SUPPLIED leaf fragment. Lets this + /// suite swap in the known vs. unknown leaf without duplicating the audience envelope per call. + static func audienceWithLeafJSON(id: String, key: String, leafJSON: String) -> String { + """ + {"id":"\(id)","key":"\(key)","type":"transient","rules":\ + {"OR":[{"AND":[{"OR_WHEN":[\(leafJSON)]}]}]}} + """ + } + + /// Renders a list of pre-built audience JSON object literals as a JSON array. + static func audiencesArrayJSON(_ audiences: [String]) -> String { + "[" + audiences.joined(separator: ",") + "]" + } + + // MARK: - Requirement 1: fail-open regression lock (siblings survive, position-independent) + + /// THE headline regression lock. THREE audiences share the array; exactly ONE (at + /// `badIndex`) carries the unknown leaf, the other two carry the known `country` leaf. + /// Parameterized over every position so the contract does not depend on where in the config + /// the bad audience happens to sit (avoids 3 near-duplicate test bodies — SonarQube 3% gate). + /// + /// MUST FAIL TODAY: the current whole-array `try?` (`ProjectConfig.swift:155-158`) throws on + /// the embedded `unknownOneOfDiscriminator` and degrades `audiences` to `nil` regardless of + /// position, so `#require(config.audiences, ...)` fails for every `badIndex`. + @Test( + "an unknown-leaf audience degrades out ALONE — siblings survive regardless of its position", + arguments: [0, 1, 2] + ) + func siblingAudiencesSurviveRegardlessOfBadPosition(badIndex: Int) throws { + var audiences = (1...3).map { index in + Self.audienceWithLeafJSON(id: "aud-\(index)", key: "k-\(index)", leafJSON: Self.knownLeafJSON) + } + audiences[badIndex] = Self.audienceWithLeafJSON( + id: "aud-\(badIndex + 1)", + key: "k-\(badIndex + 1)", + leafJSON: Self.unknownLeafJSON + ) + let config = try ProjectConfigFixtures.makeConfig( + experiencesJSON: "[]", + audiencesJSON: Self.audiencesArrayJSON(audiences) + ) + + let retained = try #require( + config.audiences, + """ + siblings must survive a single sibling's unknown discriminator at position \(badIndex), \ + not degrade the WHOLE audiences array to nil + """ + ) + #expect( + retained.count == 3, + """ + all three audiences (2 known-leaf + 1 preserved-but-unknown-leaf) must be retained \ + when the bad leaf sits at position \(badIndex) + """ + ) + } + + // MARK: - Requirement 2 (half 1 — FAILING): the bad audience itself is preserved, not dropped + + /// The audience CARRYING the unknown leaf must itself remain retrievable by id — not silently + /// dropped from the array (a naive "keep only the array, drop the bad element" degrade — the + /// literal `DegradingExperience` mirror — would satisfy requirement 1 alone but would still + /// DROP `"aud-bad"`, which is insufficient per the IOS-1 plan: "degrade a bad/unknown audience + /// ALONE" is scoped as "siblings survive", but the plan additionally requires the leaf itself be + /// SENTINEL-CAPTURED for IOS-2, which requires PRESERVING the audience, not discarding it). + /// + /// MUST FAIL TODAY: `config.audience(id:)` looks up in the (today nil) `audiences` array, so + /// this returns `nil` for `"aud-bad"` exactly as it does for every audience right now. + @Test("the audience carrying the unknown leaf is itself preserved, not silently dropped") + func audienceWithUnknownLeafIsPreservedNotDropped() throws { + let good = Self.audienceWithLeafJSON(id: "aud-good", key: "good", leafJSON: Self.knownLeafJSON) + let bad = Self.audienceWithLeafJSON(id: "aud-bad", key: "bad", leafJSON: Self.unknownLeafJSON) + let config = try ProjectConfigFixtures.makeConfig( + experiencesJSON: "[]", + audiencesJSON: Self.audiencesArrayJSON([good, bad]) + ) + + #expect( + config.audience(id: "aud-bad") != nil, + """ + the audience whose rule tree embeds an unknown rule_type discriminator must still be \ + retrievable by id, not silently dropped from the audiences array + """ + ) + } + + // MARK: - Requirement 2 (half 2 — EVIDENCE, passes today by design): sentinel fidelity + + /// EVIDENCE test, NOT itself the `ProjectConfig` contract (see the FAILING test directly above + /// for that half). Proves the exact mechanism the IOS-1 plan calls for — the ALREADY-PUBLIC, + /// ALREADY-CORRECT `SentinelWrapped` generic wrapper applied to + /// `Components.Schemas.ConfigAudience` (which already conforms `Codable & Sendable & Hashable`, + /// so `SentinelWrapped` compiles and works TODAY with ZERO + /// Sources changes) — round-trips an unknown-leaf audience to its `.sentinel` arm with the + /// leaf's `rule_type`/`value`/`negated` fully intact, structurally, inside the captured + /// `JSONValue` payload. + /// + /// This is EXPECTED TO PASS TODAY: it demonstrates the building block GREEN's per-audience + /// degrading decode must wire up (per the plan: "SENTINEL-CAPTURE the unknown leaf's raw JSON + /// via the existing SentinelWrapped/JSONValue mechanism") is ready and correct RIGHT NOW; the + /// defect is that `ProjectConfig.init(from:)` does not yet USE it for `audiences` (proven by the + /// two failing tests above). Included per the dispatch's requirement 2 ask to make the intended + /// "sentinel exposing rule_type/value/negated" shape concrete — see the file-header note on why + /// this could not instead be asserted directly against a new `ProjectConfig` accessor without + /// risking a target-wide compile break. + @Test(""" + SentinelWrapped already round-trips an unknown leaf's rule_type/value/negated \ + (infra evidence for GREEN, not the ProjectConfig contract) + """) + func sentinelWrappedAudienceAlreadyPreservesUnknownLeafFidelity() throws { + let badAudienceJSON = Self.audienceWithLeafJSON(id: "aud-bad", key: "bad", leafJSON: Self.unknownLeafJSON) + let data = Data(badAudienceJSON.utf8) + + let wrapped = try JSONDecoder().decode( + SentinelWrapped.self, + from: data + ) + + guard case let .sentinel(payload) = wrapped else { + Issue.record("an audience embedding an unknown rule_type must fall to .sentinel, got \(wrapped)") + return + } + let leaf = try Self.leafObject(fromAudienceSentinel: payload) + #expect( + Self.stringMember(named: "rule_type", in: leaf) == "bucketed_into_experience_key", + "the sentinel-captured leaf must retain its rule_type discriminator" + ) + #expect( + Self.stringMember(named: "value", in: leaf) == "exp-a", + "the sentinel-captured leaf must retain its target-experience-key value" + ) + #expect( + Self.boolMember(named: "negated", in: leaf) == false, + "the sentinel-captured leaf must retain its negated flag" + ) + } + + // MARK: - Requirement 3: happy-path parity (passes today — a regression lock, not new behavior) + + /// A config whose audiences carry ONLY known rule types must decode EXACTLY as today: nothing + /// in the future degrading path may disturb the already-correct happy path. Unlike the tests + /// above, this is a PARITY/regression lock — it already passes today (no unknown leaf is + /// involved) and must continue to pass once GREEN lands, proving the fix is additive. + @Test("a config with only known rule types decodes unchanged — no unexpected degradation") + func happyPathAllKnownAudiencesDecodeUnchanged() throws { + let first = Self.audienceWithLeafJSON(id: "aud-1", key: "k-1", leafJSON: Self.knownLeafJSON) + let second = Self.audienceWithLeafJSON(id: "aud-2", key: "k-2", leafJSON: Self.knownLeafJSON) + let config = try ProjectConfigFixtures.makeConfig( + experiencesJSON: "[]", + audiencesJSON: Self.audiencesArrayJSON([first, second]) + ) + + let audiences = try #require(config.audiences, "an all-known-rule-type audiences array must decode") + #expect(audiences.count == 2, "both known-leaf audiences must be retained") + #expect(Set(audiences.map(\.id)) == ["aud-1", "aud-2"], "both audience ids must survive intact") + + let leaf = audiences.first { $0.id == "aud-1" }?.rules?.value1.OR?.first?.AND?.first?.OR_WHEN?.first + guard case let .country(rule)? = leaf else { + Issue.record("the known country leaf must decode to .country, got \(String(describing: leaf))") + return + } + #expect(rule.value1.value2.value == "US", "the known leaf's matched country value must survive intact") + } + + // MARK: - Requirement 4: never-throws / loop-termination + + /// A config containing an unknown-discriminator audience leaf must decode WITHOUT throwing and + /// WITHOUT hanging — mirroring the `DegradingExperience` "LOOP-TERMINATION INVARIANT" comment + /// (`ProjectConfig.swift:139-147`) that the eventual per-audience unkeyed-container loop must + /// replicate. `ProjectConfig.init(from:)` already never throws for `audiences` today (the + /// whole-array `try?` swallows the error into `nil` rather than propagating it), so the + /// non-throwing half of this assertion is a plain synchronous call — no timeout mechanism is + /// invented, per the dispatch's guidance to use only conventions already present in the repo. + /// The MEANINGFUL RED signal in this test is the sibling-retention assertion that follows: it + /// fails today because the whole array degrades to nil. + @Test("decode of a config with an unknown-discriminator audience leaf terminates without throwing") + func decodeWithUnknownLeafNeverThrowsAndRetainsSiblings() throws { + let good = Self.audienceWithLeafJSON(id: "aud-good", key: "good", leafJSON: Self.knownLeafJSON) + let bad = Self.audienceWithLeafJSON(id: "aud-bad", key: "bad", leafJSON: Self.unknownLeafJSON) + let config = try ProjectConfigFixtures.makeConfig( + experiencesJSON: "[]", + audiencesJSON: Self.audiencesArrayJSON([good, bad]) + ) + + // Non-throwing/termination half: reaching this line at all is the assertion — a plain + // synchronous return, matching how ProjectConfig's degrading decode already behaves today. + let retained = try #require( + config.audiences, + "decode must terminate AND retain the good sibling even with a bad leaf present" + ) + #expect(retained.count == 2, "both the good and the preserved bad audience must survive decode") + } + + // MARK: - JSONValue navigation helpers (test-local only — no Sources/ changes) + // Mirrors the style of `ProjectConfig.stringValue(of:in:)` (Data/ProjectConfig.swift:263-268) + // but is entirely test-local: reused by the evidence test above so it is written exactly once. + + /// Test-local navigation error for a `JSONValue` shape mismatch (readable `#require`/`throw` + /// failures rather than force-unwrapping through the tree). + private enum JSONValueNavigationError: Error { + case missingMember(String) + case missingElement(Int) + } + + /// Navigates a `.sentinel` audience payload's `rules.OR[0].AND[0].OR_WHEN[0]` node — the fixed + /// envelope `audienceWithLeafJSON` always wraps a leaf in — and returns it as a raw `JSONValue` + /// (expected `.object`, matching the wire leaf shape). + static func leafObject(fromAudienceSentinel payload: JSONValue) throws -> JSONValue { + let rules = try member(named: "rules", in: payload) + let orArray = try member(named: "OR", in: rules) + let firstOr = try element(at: 0, in: orArray) + let andArray = try member(named: "AND", in: firstOr) + let firstAnd = try element(at: 0, in: andArray) + let orWhenArray = try member(named: "OR_WHEN", in: firstAnd) + return try element(at: 0, in: orWhenArray) + } + + /// The `JSONValue` of the `name`-keyed member of an `.object` node, or throws when `value` is + /// not an object or lacks that member. + private static func member(named name: String, in value: JSONValue) throws -> JSONValue { + guard case let .object(pairs) = value, let match = pairs.first(where: { $0.key == name })?.value else { + throw JSONValueNavigationError.missingMember(name) + } + return match + } + + /// The `JSONValue` at `index` of an `.array` node, or throws when `value` is not an array or + /// the index is out of range. + private static func element(at index: Int, in value: JSONValue) throws -> JSONValue { + guard case let .array(values) = value, values.indices.contains(index) else { + throw JSONValueNavigationError.missingElement(index) + } + return values[index] + } + + /// The `String` value of the `name`-keyed member of an `.object` node, or `nil` when absent or + /// not a JSON string. + static func stringMember(named name: String, in value: JSONValue) -> String? { + guard + case let .object(pairs) = value, + case let .string(string)? = pairs.first(where: { $0.key == name })?.value + else { + return nil + } + return string + } + + /// The `Bool` value of the `name`-keyed member of an `.object` node, or `nil` when absent or not + /// a JSON boolean. + static func boolMember(named name: String, in value: JSONValue) -> Bool? { + guard + case let .object(pairs) = value, + case let .bool(bool)? = pairs.first(where: { $0.key == name })?.value + else { + return nil + } + return bool + } +} diff --git a/Tests/ConvertSwiftSDKCoreTests/Experience/MutualExclusionExperienceManagerTests.swift b/Tests/ConvertSwiftSDKCoreTests/Experience/MutualExclusionExperienceManagerTests.swift new file mode 100644 index 0000000..99de312 --- /dev/null +++ b/Tests/ConvertSwiftSDKCoreTests/Experience/MutualExclusionExperienceManagerTests.swift @@ -0,0 +1,343 @@ +// Tests/ConvertSwiftSDKCoreTests/Experience/MutualExclusionExperienceManagerTests.swift +// +// RED-phase suite for M2 (iOS mutual-exclusion qs-04): the REAL, read-only resolver wired into +// `ExperienceManager`'s audience gate, exercised END-TO-END through the PUBLIC `selectVariation` +// API — no new Sources symbols are referenced here (the resolver / async-`audiencePasses` / +// `matching_options` composition change is entirely INTERNAL to `ExperienceManager`), so this +// file COMPILES today and FAILS AT RUNTIME. Spec of record: +// _bmad-output/implementation-artifacts/2026-06-09-convert-ios-sdk/qs-04-mutual-exclusion-rule.md +// Task/plan: work/2026-07-15-ios-sdk-mutual-exclusion/workflow-state.yaml. +// +// ── Why this is RED today (two independent runtime gaps, not a compile error) ────────────────── +// (1) `ExperienceManager.audiencePasses` (Experience/ExperienceManager.swift:329-341) resolves each +// attached audience via `config.audience(id:)`, then reads its TYPED `rules?.value1`. For a +// DEGRADED audience (its rule tree embeds the unrecognised `bucketed_into_experience_key` leaf) +// that typed `rules` is `nil` by construction (`ProjectConfig+AudienceDecoding.swift`'s +// `reconstructAudience(fromSentinelPayload:)` never populates it) — so a degraded audience emits +// NO groups. With the sole attached audience emitting zero groups (the pure-exclusion AC2/AC3/AC5/ +// AC8 scenarios), `ruleManager.evaluate(rules: [], against:)` fails CLOSED (`Rules/RuleManager +// .swift:53-61`) — an experience gated on a degraded mutual-exclusion audience returns `nil` for +// EVERY visitor, unconditionally, regardless of whether the visitor is actually bucketed into the +// target. Wrong for the never-ran-target case (AC2's second half) and produces no warning naming +// an unresolved target key (AC8). +// (2) `audiencePasses` CONCATENATES every attached audience's flattened groups into ONE flat outer +// OR (Experience/ExperienceManager.swift:326-328/363-369) and never reads +// `full.settings?.matching_options?.audiences` at all — so the qs-04 AC6 two-audience `ALL`/`ANY` +// scenarios (below) do not merely fail on the degraded audience (gap 1); even a fully-typed generic +// second audience would be OR-combined with the exclusion audience regardless of the experience's +// declared `matching_options.audiences`, which is a SEPARATE, additional divergence from JS parity +// (`javascript-sdk/packages/data/src/data-manager.ts:419-428`, `_isBucketingExclusionRule`/ +// `_resolveBucketingExclusion`/`filterMatchedRecordsWithRule`, `data-manager.ts:1246-1345`). +// +// GREEN detects a degraded audience's stateful leaf via `RuleAdapter.flatten(_ sentinelRuleTree: +// JSONValue)` (reading the `"rules"` member off `ProjectConfig.degradedAudienceSentinels[id]`), +// resolves an EXCLUSION audience's match via a whole-audience-override seam (mirroring JS's +// `_resolveBucketingExclusion` — sibling leaves in the SAME audience tree are NEVER evaluated), +// resolves a GENERIC audience's match via the existing `RuleManager.evaluate` (unchanged, AC7), +// then composes the PER-AUDIENCE match booleans via `settings.matching_options.audiences` +// (`ALL` ⇒ every attached audience must match; `ANY`/absent ⇒ at least one must match — JS parity). +// +// ── Fixtures ───────────────────────────────────────────────────────────────────────────────── +// `MutualExclusionFixtures` (Support/MutualExclusionFixtures.swift) builds: +// - `twoExperienceMutualExclusionConfig` — `exp-a` (always buckets, TARGET) + `exp-b` gated on +// ONE degraded (pure-exclusion) audience — used by AC2/AC3/AC5/AC8. +// - `twoAudienceMutualExclusionConfig` — `exp-a` (TARGET) + `exp-b` gated on TWO SEPARATE +// audiences (a dedicated exclusion audience + a generic `country` audience), composed via +// `settings.matching_options.audiences` — used by the AC6 tests below (the JS-parity +// two-audience shape; the PRIOR mixed-single-audience shape, which combined a stateful and a +// generic leaf inside ONE audience's tree via ordinary AND/OR, corresponded to no real JS code +// path and has been REMOVED — see `MutualExclusionFixtures.swift`'s header). +// +// ── Test-hygiene ───────────────────────────────────────────────────────────────────────────── +// `attributes` is `[:]` (the default) everywhere except the two AC6 combination tests, which need +// a `country` value to drive the generic audience's leaf — proving AC4 structurally for every +// pure-exclusion scenario. EventBus delivery is asynchronous (`fire` dispatches each callback as +// an independent `MainActor` `Task`), so every fire-count read goes through `drain()` (a +// `MainActor.run {}` executor barrier) — mirrors `ExperienceManagerTests.drain()` verbatim. +// Every scenario shares ONE subject factory / one select helper (SonarQube 3% convention already +// established by the sibling `ExperienceManagerTests`), so no ≥10-line block is copy-pasted. + +import Foundation +import Testing +@testable import ConvertSwiftSDKCore + +@Suite("ExperienceManager mutual-exclusion end-to-end (bucketed_into_experience_key) — IOS-3 RED") +struct MutualExclusionExperienceManagerTests { + + // MARK: - Shared identifiers + + private enum Ids { + static let account = "a" + static let project = "p" + static let visitorRanExpA = "v-ran-a" + static let visitorFresh = "v-fresh" + + /// The storeKey the pipeline derives — `--`. + static func storeKey(_ visitor: String) -> String { "\(account)-\(project)-\(visitor)" } + } + + // MARK: - Subject factory (SonarQube 3% new-duplicated-lines gate) + + /// Builds the subject with REAL collaborators wired to the passed (or default) doubles — + /// mirrors `ExperienceManagerTests.makeExperienceManager` (a sibling suite, not reachable + /// from this file's `private` scope, so re-declared here rather than forked in shape). + private func makeExperienceManager( + decisionStore: DecisionStore = DecisionStore(logger: MockLogger(), fileStore: MockFileStore()), + eventSink: MockEventSink = MockEventSink(), + eventBus: EventBus = EventBus(), + logger: MockLogger = MockLogger() + ) -> ExperienceManager { + ExperienceManager( + ruleManager: RuleManager(logger: logger), + bucketingManager: BucketingManager(eventSink: eventSink, logger: logger), + decisionStore: decisionStore, + eventBus: eventBus, + logger: logger + ) + } + + /// Invokes `selectVariation` with the shared account/project ids and per-scenario visitor / + /// attributes, with tracking always on (the mutual-exclusion rule is orthogonal to + /// `enableTracking`, already covered by `ExperienceManagerTests`). + private func select( + _ subject: ExperienceManager, + key: String, + in config: ProjectConfig, + visitorId: String, + attributes: [String: String] = [:] + ) async -> Variation? { + await subject.selectVariation( + forKey: key, + in: config, + visitorId: visitorId, + accountId: Ids.account, + projectId: Ids.project, + attributes: attributes, + locationProperties: [:], + enableTracking: true + ) + } + + /// Lets already-dispatched `MainActor` callbacks run before assertions read a capture. + /// Mirrors `ExperienceManagerTests.drain()` verbatim (see that file's doc for why + /// `Task.yield()` does not suffice). + private func drain() async { + await MainActor.run { } + } + + // MARK: - AC2 + AC4 — end-to-end exclusion, empty attributes throughout + + /// A visitor already bucketed into `exp-a` is excluded from `exp-b` (`negated: true` against + /// `exp-a`) — the core mutual-exclusion behavior, with `attributes` empty throughout (AC4). + @Test("AC2/AC4: a visitor already bucketed into exp-a is excluded from exp-b (negated rule)") + func visitorBucketedIntoExpAIsExcludedFromExpB() async throws { + let config = try MutualExclusionFixtures.twoExperienceMutualExclusionConfig( + audienceRulesJSON: MutualExclusionFixtures.singleLeafRulesJSON( + MutualExclusionFixtures.statefulLeafJSON(targetExperienceKey: "exp-a", negated: true) + ) + ) + let subject = makeExperienceManager() + + let variationA = await select(subject, key: "exp-a", in: config, visitorId: Ids.visitorRanExpA) + #expect(variationA != nil, "exp-a has no gates and must bucket") + + let variationB = await select(subject, key: "exp-b", in: config, visitorId: Ids.visitorRanExpA) + #expect(variationB == nil, "a visitor already bucketed into exp-a must be excluded from exp-b") + } + + /// A DIFFERENT, fresh visitor who never ran `exp-a` buckets into `exp-b` normally — the + /// negated exclusion dissolves when the visitor was never bucketed into the target. + @Test("AC2/AC4: a fresh visitor who never ran exp-a buckets into exp-b normally") + func freshVisitorNeverRanExpABucketsIntoExpB() async throws { + let config = try MutualExclusionFixtures.twoExperienceMutualExclusionConfig( + audienceRulesJSON: MutualExclusionFixtures.singleLeafRulesJSON( + MutualExclusionFixtures.statefulLeafJSON(targetExperienceKey: "exp-a", negated: true) + ) + ) + let subject = makeExperienceManager() + + let variationB = await select(subject, key: "exp-b", in: config, visitorId: Ids.visitorFresh) + + #expect( + variationB?.experienceKey == "exp-b", + "a visitor who never ran exp-a must bucket into exp-b normally (negated exclusion dissolves)" + ) + } + + // MARK: - AC3 — cross-relaunch persistence (row 8) + + /// `exp-a`'s decision, persisted by ONE `DecisionStore` instance, still excludes `exp-b` after + /// a FRESH `DecisionStore` + fresh `ExperienceManager` are constructed against the SAME + /// `MockFileStore` container and rehydrated via `loadFromDisk()` — simulating an app relaunch. + @Test("AC3: exp-a's decision persisted by one DecisionStore excludes exp-b after a fresh relaunch") + func crossRelaunchPersistenceExcludesExpB() async throws { + let config = try MutualExclusionFixtures.twoExperienceMutualExclusionConfig( + audienceRulesJSON: MutualExclusionFixtures.singleLeafRulesJSON( + MutualExclusionFixtures.statefulLeafJSON(targetExperienceKey: "exp-a", negated: true) + ) + ) + let sharedFiles = MockFileStore() + let firstLaunchStore = DecisionStore(logger: MockLogger(), fileStore: sharedFiles) + let firstLaunchSubject = makeExperienceManager(decisionStore: firstLaunchStore) + let variationA = await select( + firstLaunchSubject, key: "exp-a", in: config, visitorId: Ids.visitorRanExpA + ) + #expect(variationA != nil, "exp-a has no gates and must bucket on the first launch") + + // Simulate relaunch: a FRESH DecisionStore + fresh ExperienceManager against the SAME + // MockFileStore container, rehydrated from disk — NOT carried over in-memory. + let relaunchStore = DecisionStore(logger: MockLogger(), fileStore: sharedFiles) + await relaunchStore.loadFromDisk() + let relaunchSubject = makeExperienceManager(decisionStore: relaunchStore) + + let variationB = await select( + relaunchSubject, key: "exp-b", in: config, visitorId: Ids.visitorRanExpA + ) + + #expect( + variationB == nil, + "row 8: a decision persisted before relaunch must still exclude exp-b after rehydration" + ) + } + + // MARK: - AC5 — read-only: no new bucketing / storage write / tracking event from the check + + /// Evaluating `exp-b`'s exclusion audience must not itself bucket the target, write a new + /// sticky decision, or enqueue a tracking event — the ONLY store entry / enqueue / fire must + /// be the ones `exp-a`'s OWN run already produced. + @Test("AC5: evaluating exp-b's exclusion rule triggers no new bucketing, write, or tracking event") + func exclusionRuleEvaluationIsReadOnly() async throws { + let config = try MutualExclusionFixtures.twoExperienceMutualExclusionConfig( + audienceRulesJSON: MutualExclusionFixtures.singleLeafRulesJSON( + MutualExclusionFixtures.statefulLeafJSON(targetExperienceKey: "exp-a", negated: true) + ) + ) + let store = DecisionStore(logger: MockLogger(), fileStore: MockFileStore()) + let sink = MockEventSink() + let bus = EventBus() + let subject = makeExperienceManager(decisionStore: store, eventSink: sink, eventBus: bus) + let fireCount = LockedBox(0) + _ = await bus.on(.bucketing) { _ in fireCount.withLock { $0 += 1 } } + + _ = await select(subject, key: "exp-a", in: config, visitorId: Ids.visitorRanExpA) + await drain() + let eventsAfterA = await sink.recordedEvents().count + let firesAfterA = fireCount.get + + let variationB = await select(subject, key: "exp-b", in: config, visitorId: Ids.visitorRanExpA) + await drain() + + #expect( + variationB == nil, + "the exclusion must still hold for this read-only assertion to be meaningful" + ) + let eventsAfterB = await sink.recordedEvents().count + #expect(eventsAfterB == eventsAfterA, "evaluating exp-b's audience must enqueue no new tracking event") + #expect(fireCount.get == firesAfterA, "evaluating exp-b's audience must fire no new .bucketing event") + let bucketing = await store.bucketingDecisions(forStoreKey: Ids.storeKey(Ids.visitorRanExpA)) + #expect( + bucketing.count == 1, + "only exp-a's decision may be stored; exp-b must not be bucketed into or written" + ) + } + + // MARK: - AC6 — cross-AUDIENCE combination via `matching_options.audiences` (ALL / ANY) + // + // JS-parity two-audience shape (`MutualExclusionFixtures.twoAudienceMutualExclusionConfig`): + // `exp-b` carries TWO SEPARATE attached audiences — a dedicated exclusion audience (the negated + // `bucketed_into_experience_key` leaf ALONE, targeting `exp-a`) and a generic `country == "US"` + // audience — composed via `exp-b`'s `settings.matching_options.audiences`. `audiencePasses` + // never reads `matching_options` at all today (it OR-concatenates every attached audience's + // groups unconditionally), so the ALL scenario below is the one that discriminates RED from + // GREEN: today a visitor excluded by the exclusion audience alone still passes because the + // generic audience's OR carries it through, regardless of the declared `ALL` requirement. + + /// ALL: both the dedicated exclusion audience AND the generic `country` audience must match — + /// a visitor bucketed into `exp-a` fails the (negated) exclusion audience, so `exp-b` must be + /// excluded even though the generic `country == "US"` audience independently matches (today's + /// flat-OR gate incorrectly lets this visitor through via the passing generic audience alone). + @Test("AC6: ALL — both the exclusion audience and the generic country audience must match") + func allMatchingOptionRequiresBothAudiencesToPass() async throws { + let config = try MutualExclusionFixtures.twoAudienceMutualExclusionConfig(matchingOptions: "all") + let subject = makeExperienceManager() + + let variationA = await select(subject, key: "exp-a", in: config, visitorId: Ids.visitorRanExpA) + #expect(variationA != nil, "exp-a has no gates and must bucket") + + let excludedDespiteGenericMatch = await select( + subject, key: "exp-b", in: config, visitorId: Ids.visitorRanExpA, attributes: ["country": "US"] + ) + #expect( + excludedDespiteGenericMatch == nil, + "ALL: bucketed into exp-a fails the exclusion audience even though country==US passes -> excluded" + ) + + let passesWhenBothMatch = await select( + subject, key: "exp-b", in: config, visitorId: "v-all-fresh-us", attributes: ["country": "US"] + ) + #expect( + passesWhenBothMatch != nil, + "ALL: never ran exp-a (exclusion audience passes) AND country==US (generic passes) -> exp-b serves" + ) + } + + /// ANY: either audience matching suffices — a visitor bucketed into `exp-a` fails the + /// exclusion audience, but the generic `country == "US"` audience compensates, so `exp-b` + /// still serves; a visitor matching NEITHER audience is excluded. + @Test("AC6: ANY — either the exclusion audience or the generic country audience matching suffices") + func anyMatchingOptionEitherAudiencePassing() async throws { + let config = try MutualExclusionFixtures.twoAudienceMutualExclusionConfig(matchingOptions: "any") + let subject = makeExperienceManager() + + let variationA = await select(subject, key: "exp-a", in: config, visitorId: Ids.visitorRanExpA) + #expect(variationA != nil, "exp-a has no gates and must bucket") + + let passesViaGenericAudience = await select( + subject, key: "exp-b", in: config, visitorId: Ids.visitorRanExpA, attributes: ["country": "US"] + ) + #expect( + passesViaGenericAudience != nil, + "ANY: the exclusion audience fails (bucketed into exp-a) but country==US passes -> exp-b serves" + ) + + // A SEPARATE visitor for the neither-matches assertion (not `Ids.visitorRanExpA` again): that + // visitor already holds a STICKY exp-b decision from the `passesViaGenericAudience` call above, + // and a sticky hit short-circuits every gate (by design) regardless of `matching_options` — reusing + // it here would test sticky-return semantics, not the ALL/ANY composition. This visitor is bucketed + // into exp-a fresh (so the exclusion audience fails the same way) but has NO prior exp-b decision. + let visitorNeitherMatches = "v-any-neither" + let priorExpA = await select(subject, key: "exp-a", in: config, visitorId: visitorNeitherMatches) + #expect(priorExpA != nil, "exp-a has no gates and must bucket for the neither-matches visitor too") + let excludedWhenNeitherMatches = await select( + subject, key: "exp-b", in: config, visitorId: visitorNeitherMatches, attributes: ["country": "UK"] + ) + #expect( + excludedWhenNeitherMatches == nil, + "ANY: the exclusion audience fails AND country==UK fails the generic audience -> exp-b excluded" + ) + } + + // MARK: - AC8 — unknown target experience key logs a warning naming that key + + /// A rule targeting an experience key absent from the config resolves `bucketedRaw = false` + /// (unknown target) and logs a warning NAMING the unresolved key — not the generic + /// "empty rule set" message the current (broken) wiring emits. + @Test("AC8: targeting an unknown experience key logs a warning naming it") + func unknownTargetExperienceKeyLogsWarning() async throws { + let config = try MutualExclusionFixtures.twoExperienceMutualExclusionConfig( + audienceRulesJSON: MutualExclusionFixtures.singleLeafRulesJSON( + MutualExclusionFixtures.statefulLeafJSON(targetExperienceKey: "exp-zz", negated: false) + ) + ) + let logger = MockLogger() + let subject = makeExperienceManager(logger: logger) + + let variation = await select(subject, key: "exp-b", in: config, visitorId: Ids.visitorFresh) + + #expect(variation == nil, "an unrecognised target resolves bucketedRaw=false -> matched=false") + #expect( + logger.entries().contains { $0.level == .warn && $0.message.contains("exp-zz") }, + "the unknown target key must be named in a warning" + ) + } +} diff --git a/Tests/ConvertSwiftSDKCoreTests/Fixtures/cross-sdk-bucketing-vectors.json b/Tests/ConvertSwiftSDKCoreTests/Fixtures/cross-sdk-bucketing-vectors.json new file mode 100644 index 0000000..d101550 --- /dev/null +++ b/Tests/ConvertSwiftSDKCoreTests/Fixtures/cross-sdk-bucketing-vectors.json @@ -0,0 +1,701 @@ +[ + { + "description": "[packed-regression] v11 thirds 15% (5/5/5): value 293 (visitor thirds-core-O-1) lands in O's band [0,500) -> O", + "experienceId": "900000001", + "visitorId": "thirds-core-O-1", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": "O" + }, + { + "description": "[packed-regression] v11 thirds 15% (5/5/5): value 601 (visitor thirds-flip-V1-to-O-66) lands in V1's band [500,1000) -> V1", + "experienceId": "900000001", + "visitorId": "thirds-flip-V1-to-O-66", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[packed-regression][incident-flip] v11 thirds 25% (8.333.../each): SAME visitor as above (value 601) now lands in O's RELOCATED band [0,833.33) -> reassigned to O. Documents the Distilled.ie incident: raising total allocation FLIPPED this visitor from V1 to O under the packed cumulative walk", + "experienceId": "900000001", + "visitorId": "thirds-flip-V1-to-O-66", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": "O" + }, + { + "description": "[packed-regression] v11 thirds 15% (5/5/5): value 1213 (visitor thirds-flip-V2-to-V1-5) lands in V2's band [1000,1500) -> V2", + "experienceId": "900000001", + "visitorId": "thirds-flip-V2-to-V1-5", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": "V2" + }, + { + "description": "[packed-regression][incident-flip] v11 thirds 25% (8.333.../each): SAME visitor as above (value 1213) now lands in V1's RELOCATED band [833.33,1666.67) -> reassigned to V1. Second flip from the same incident (V2 -> V1)", + "experienceId": "900000001", + "visitorId": "thirds-flip-V2-to-V1-5", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[packed-regression] v11 thirds 15% (5/5/5): value 877 (visitor thirds-stable-V1-77) lands in V1's band [500,1000) -> V1", + "experienceId": "900000001", + "visitorId": "thirds-stable-V1-77", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[packed-regression] v11 thirds 25% (8.333.../each): SAME visitor as above (value 877) still lands in V1's band [833.33,1666.67) -> V1 unaffected. Contrast vector: not every visitor flips on a packed raise, only those whose value falls inside a relocated sub-range", + "experienceId": "900000001", + "visitorId": "thirds-stable-V1-77", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[packed-regression][sub-100%-exhaustion] v11 thirds 15% (5/5/5): value 1547 (visitor thirds-null-to-V1-25pct-48) exceeds the 15% total allocation -> not bucketed", + "experienceId": "900000001", + "visitorId": "thirds-null-to-V1-25pct-48", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": null + }, + { + "description": "[packed-regression][lower-ejection-contrast] v11 thirds 25% (8.333.../each): SAME visitor as above (value 1547) is newly admitted into V1's band [833.33,1666.67) at 25%. Read in reverse (25% -> 15%), this is AC3's packed lower-skew contrast vector: lowering coverage EJECTS this visitor to null, it is never reassigned to a different arm", + "experienceId": "900000001", + "visitorId": "thirds-null-to-V1-25pct-48", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[packed-regression][sub-100%-exhaustion] v11 thirds 15% (5/5/5): value 1733 (visitor thirds-null-to-V2-25pct-majority-6) exceeds the 15% total allocation -> not bucketed", + "experienceId": "900000001", + "visitorId": "thirds-null-to-V2-25pct-majority-6", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": null + }, + { + "description": "[packed-regression][incident-skew] v11 thirds 25% (8.333.../each): SAME visitor as above (value 1733) is newly admitted into V2's band [1666.67,2500) at 25%. Documents the incident's uneven skew: the newly opened packed band overwhelmingly favors the LAST arm (V2), not an even 3-way split", + "experienceId": "900000001", + "visitorId": "thirds-null-to-V2-25pct-majority-6", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": "V2" + }, + { + "description": "[packed-regression] v11 thirds 15% (5/5/5): value 3134 (visitor thirds-idle-both-packed-3) exceeds the 15% total allocation -> not bucketed", + "experienceId": "900000001", + "visitorId": "thirds-idle-both-packed-3", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": null + }, + { + "description": "[packed-regression] v11 thirds 25% (8.333.../each): SAME visitor as above (value 3134) ALSO exceeds the 25% total allocation -> not bucketed at either coverage", + "experienceId": "900000001", + "visitorId": "thirds-idle-both-packed-3", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": null + }, + { + "description": "[anchored-basic-thirds][raise-superset-core] v12 thirds 15% (5/5/5): value 293 (visitor thirds-core-O-1) lands in O's band [0,500) -> O", + "experienceId": "900000001", + "visitorId": "thirds-core-O-1", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": "O" + }, + { + "description": "[anchored-basic-thirds][raise-superset-core] v12 thirds 25% (8.333.../each): SAME visitor as above (value 293) stays in O's SUPERSET band [0,833.33) -> O. No flip (AC2)", + "experienceId": "900000001", + "visitorId": "thirds-core-O-1", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": "O" + }, + { + "description": "[anchored-basic-thirds][raise-superset-core] v12 thirds 15% (5/5/5): value 3617 (visitor thirds-anchored-V1-core-1) lands in V1's band [3333.33,3833.33) -> V1", + "experienceId": "900000001", + "visitorId": "thirds-anchored-V1-core-1", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[anchored-basic-thirds][raise-superset-core] v12 thirds 25% (8.333.../each): SAME visitor as above (value 3617) stays in V1's SUPERSET band [3333.33,4166.67) -> V1. No flip (AC2)", + "experienceId": "900000001", + "visitorId": "thirds-anchored-V1-core-1", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[anchored-basic-thirds][raise-superset-core] v12 thirds 15% (5/5/5): value 6871 (visitor thirds-anchored-V2-core-24) lands in V2's band [6666.67,7166.67) -> V2", + "experienceId": "900000001", + "visitorId": "thirds-anchored-V2-core-24", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": "V2" + }, + { + "description": "[anchored-basic-thirds][raise-superset-core] v12 thirds 25% (8.333.../each): SAME visitor as above (value 6871) stays in V2's SUPERSET band [6666.67,7500) -> V2. No flip (AC2)", + "experienceId": "900000001", + "visitorId": "thirds-anchored-V2-core-24", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": "V2" + }, + { + "description": "[per-sliver-admission][lower-ejection] v12 thirds 15% (5/5/5): value 601 (visitor thirds-flip-V1-to-O-66) is NOT bucketed (falls between O's band [0,500) and V1's band [3333.33,3833.33))", + "experienceId": "900000001", + "visitorId": "thirds-flip-V1-to-O-66", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": null + }, + { + "description": "[per-sliver-admission] v12 thirds 25% (8.333.../each): SAME visitor as above (value 601) is newly admitted into O's growth sliver [500,833.33) at 25%. Contrast with the packed vector for this same value (V1 -> O flip): anchored never reassigns an already-bucketed visitor, it only ever admits from null", + "experienceId": "900000001", + "visitorId": "thirds-flip-V1-to-O-66", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": "O" + }, + { + "description": "[per-sliver-admission][lower-ejection] v12 thirds 15% (5/5/5): value 3899 (visitor thirds-anchored-V1-sliver-15) is NOT bucketed (exceeds V1's band [3333.33,3833.33))", + "experienceId": "900000001", + "visitorId": "thirds-anchored-V1-sliver-15", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": null + }, + { + "description": "[per-sliver-admission] v12 thirds 25% (8.333.../each): SAME visitor as above (value 3899) is newly admitted into V1's growth sliver (3833.33,4166.67) at 25%", + "experienceId": "900000001", + "visitorId": "thirds-anchored-V1-sliver-15", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[per-sliver-admission][lower-ejection] v12 thirds 15% (5/5/5): value 7353 (visitor thirds-anchored-V2-sliver-14) is NOT bucketed (exceeds V2's band [6666.67,7166.67))", + "experienceId": "900000001", + "visitorId": "thirds-anchored-V2-sliver-14", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": null + }, + { + "description": "[per-sliver-admission] v12 thirds 25% (8.333.../each): SAME visitor as above (value 7353) is newly admitted into V2's growth sliver (7166.67,7500) at 25%", + "experienceId": "900000001", + "visitorId": "thirds-anchored-V2-sliver-14", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": "V2" + }, + { + "description": "[anchored-idle][incident-fix-contrast] v12 thirds 15% (5/5/5): value 1213 (visitor thirds-flip-V2-to-V1-5) is NOT bucketed under anchored. Contrast with the packed vectors for this same value (V2 -> V1 flip): anchored has no arm assignment at all here at either coverage, so there is no reassignment risk", + "experienceId": "900000001", + "visitorId": "thirds-flip-V2-to-V1-5", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": null + }, + { + "description": "[anchored-idle][incident-fix-contrast] v12 thirds 25% (8.333.../each): SAME visitor as above (value 1213) is STILL NOT bucketed under anchored at the higher coverage either", + "experienceId": "900000001", + "visitorId": "thirds-flip-V2-to-V1-5", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": null + }, + { + "description": "[anchored-idle] v12 thirds 15% (5/5/5): value 5848 (visitor thirds-idle-both-anchored-mid-2) is idle (falls between V1's and V2's bands)", + "experienceId": "900000001", + "visitorId": "thirds-idle-both-anchored-mid-2", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": null + }, + { + "description": "[anchored-idle] v12 thirds 25% (8.333.../each): SAME visitor as above (value 5848) is STILL idle at the higher coverage (still between V1's and V2's bands)", + "experienceId": "900000001", + "visitorId": "thirds-idle-both-anchored-mid-2", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": null + }, + { + "description": "[anchored-idle] v12 thirds 15% (5/5/5): value 8455 (visitor thirds-idle-both-high-0) exceeds V2's band -> idle", + "experienceId": "900000001", + "visitorId": "thirds-idle-both-high-0", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": null + }, + { + "description": "[anchored-idle] v12 thirds 25% (8.333.../each): SAME visitor as above (value 8455) STILL exceeds V2's band -> idle", + "experienceId": "900000001", + "visitorId": "thirds-idle-both-high-0", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": null + }, + { + "description": "[stopped-arm-stability] v12, O=10/V1=80/V2=10 all RUNNING: value 102 (visitor anchor-gate-visitor-106) -> O", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-106", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "O" + }, + { + "description": "[stopped-arm-stability] v12, O=10/V1=80 STOPPED/V2=10: SAME visitor as above (value 102) -> O, unaffected by V1's stop (AC4 anchor stability)", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-106", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "stopped"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "O" + }, + { + "description": "[stopped-arm-stability] v12, O=10/V1=80/V2=10 all RUNNING: value 9807 (visitor anchor-gate-visitor-162) -> V2", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-162", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "V2" + }, + { + "description": "[stopped-arm-stability] v12, O=10/V1=80 STOPPED/V2=10: SAME visitor as above (value 9807) -> V2's anchor (9000) is byte-identical whether V1 runs or is stopped (AC4)", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-162", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "stopped"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "V2" + }, + { + "description": "[stopped-arm-stability] v12, O=10/V1=80/V2=10 all RUNNING: value 4957 (visitor anchor-gate-visitor-17) -> V1", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-17", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[stopped-arm-stability] v12, O=10/V1=80 STOPPED/V2=10: SAME visitor as above (value 4957) -> stopped V1 keeps its weight (anchor stable at 1000) but has zero width, so it is never selected -> not bucketed", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-17", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "stopped"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": null + }, + { + "description": "[ta-zero-width] v12, O=2/V1=47/Z=0(explicit)/V2=1: value 102 (visitor anchor-gate-visitor-106) -> O", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-106", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 2, "status": "running"}, + {"id": "V1", "traffic_allocation": 47, "status": "running"}, + {"id": "Z", "traffic_allocation": 0, "status": "running"}, + {"id": "V2", "traffic_allocation": 1, "status": "running"} + ], + "expected": "O" + }, + { + "description": "[ta-zero-width] v12, O=2/V1=47/Z=0(explicit)/V2=1: value 4957 (visitor anchor-gate-visitor-17) -> V1. Z's explicit zero allocation is never defaulted to 100 and never perturbs V1's anchor; Z is never selected", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-17", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 2, "status": "running"}, + {"id": "V1", "traffic_allocation": 47, "status": "running"}, + {"id": "Z", "traffic_allocation": 0, "status": "running"}, + {"id": "V2", "traffic_allocation": 1, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[ta-zero-width] v12, O=2/V1=47/Z=0(explicit)/V2=1: value 9807 (visitor anchor-gate-visitor-162) -> V2. Z's zero-width entry does not shift V2's anchor since it contributes zero weight", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-162", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 2, "status": "running"}, + {"id": "V1", "traffic_allocation": 47, "status": "running"}, + {"id": "Z", "traffic_allocation": 0, "status": "running"}, + {"id": "V2", "traffic_allocation": 1, "status": "running"} + ], + "expected": "V2" + }, + { + "description": "[nan-default] v12, single arm DEFAULT with traffic_allocation OMITTED (isNaN(ta) -> 100.0 default, full traffic space): any visitor is bucketed into DEFAULT", + "experienceId": "900000001", + "visitorId": "nan-default-visitor", + "version": 12, + "variations": [ + {"id": "DEFAULT", "status": "running"} + ], + "expected": "DEFAULT" + }, + { + "description": "[nan-default] v11, SAME single arm DEFAULT with traffic_allocation OMITTED: packed path also defaults to 100.0 -> DEFAULT (v11 === v12 for the NaN-default single-arm case)", + "experienceId": "900000001", + "visitorId": "nan-default-visitor", + "version": 11, + "variations": [ + {"id": "DEFAULT", "status": "running"} + ], + "expected": "DEFAULT" + }, + { + "description": "[nan-default] v12, two arms B(traffic_allocation=5) and A(traffic_allocation OMITTED -> defaults to 100): value 102 (visitor anchor-gate-visitor-106) falls in B's own band [0,500) -> B (isNaN default on A does not swallow values clearly inside B's own range; config order wins ties)", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-106", + "version": 12, + "variations": [ + {"id": "B", "traffic_allocation": 5, "status": "running"}, + {"id": "A", "status": "running"} + ], + "expected": "B" + }, + { + "description": "[nan-default] v12, two arms B(traffic_allocation=5) and A(traffic_allocation OMITTED -> defaults to 100): value 9807 (visitor anchor-gate-visitor-162) falls well inside A's defaulted 100-weight band -> A", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-162", + "version": 12, + "variations": [ + {"id": "B", "traffic_allocation": 5, "status": "running"}, + {"id": "A", "status": "running"} + ], + "expected": "A" + }, + { + "description": "[single-arm-v11-eq-v12] v11, single arm ONLY at traffic_allocation=100: any visitor -> ONLY", + "experienceId": "900000001", + "visitorId": "single-arm-visitor", + "version": 11, + "variations": [ + {"id": "ONLY", "traffic_allocation": 100, "status": "running"} + ], + "expected": "ONLY" + }, + { + "description": "[single-arm-v11-eq-v12] v12, SAME single arm ONLY at traffic_allocation=100: anchored path -> ONLY (v11 === v12 for a single full-allocation arm)", + "experienceId": "900000001", + "visitorId": "single-arm-visitor", + "version": 12, + "variations": [ + {"id": "ONLY", "traffic_allocation": 100, "status": "running"} + ], + "expected": "ONLY" + }, + { + "description": "[100pct-total-v11-eq-v12] v11, O=10/V1=80/V2=10 (total 100%, all running): value 102 (visitor anchor-gate-visitor-106) -> O", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-106", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "O" + }, + { + "description": "[100pct-total-v11-eq-v12] v12, SAME O=10/V1=80/V2=10 config: SAME visitor (value 102) -> O. Packed and anchored coincide exactly at 100% total allocation", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-106", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "O" + }, + { + "description": "[100pct-total-v11-eq-v12] v11, O=10/V1=80/V2=10 (total 100%, all running): value 4957 (visitor anchor-gate-visitor-17) -> V1", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-17", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[100pct-total-v11-eq-v12] v12, SAME O=10/V1=80/V2=10 config: SAME visitor (value 4957) -> V1. Packed and anchored coincide exactly at 100% total allocation", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-17", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[100pct-total-v11-eq-v12] v11, O=10/V1=80/V2=10 (total 100%, all running): value 9807 (visitor anchor-gate-visitor-162) -> V2", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-162", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "V2" + }, + { + "description": "[100pct-total-v11-eq-v12] v12, SAME O=10/V1=80/V2=10 config: SAME visitor (value 9807) -> V2. Packed and anchored coincide exactly at 100% total allocation", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-162", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "V2" + }, + { + "description": "[boundary-hit] v12, O=10/V1=80/V2=10: value 999 (visitor boundary-999-25207) is just below V1's anchor (1000) -> O (upper edge of O's half-open range)", + "experienceId": "900000001", + "visitorId": "boundary-999-25207", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "O" + }, + { + "description": "[boundary-hit] v12, O=10/V1=80/V2=10: value 1000 (visitor boundary-1000-1145) EQUALS V1's anchor exactly -> V1 (anchor is inclusive: anchor <= value)", + "experienceId": "900000001", + "visitorId": "boundary-1000-1145", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[boundary-hit] v12, O=10/V1=80/V2=10: value 8999 (visitor boundary-8999-359) is just below V2's anchor (9000) -> V1 (upper edge of V1's half-open range)", + "experienceId": "900000001", + "visitorId": "boundary-8999-359", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[boundary-hit] v12, O=10/V1=80/V2=10: value 9000 (visitor boundary-9000-9598) EQUALS V2's anchor exactly -> V2 (anchor is inclusive)", + "experienceId": "900000001", + "visitorId": "boundary-9000-9598", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "V2" + }, + { + "description": "[boundary-hit] v12, O=10/V1=80/V2=10: value 9999 (visitor boundary-9999-5699) is the maximum representable traffic value, still inside V2's range -> V2", + "experienceId": "900000001", + "visitorId": "boundary-9999-5699", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "V2" + }, + { + "description": "[total-weight-zero] v12, two arms both traffic_allocation=0 (one running, one stopped): totalWeight is 0 -> not bucketed regardless of visitor", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-106", + "version": 12, + "variations": [ + {"id": "A", "traffic_allocation": 0, "status": "running"}, + {"id": "B", "traffic_allocation": 0, "status": "stopped"} + ], + "expected": null + }, + { + "description": "[total-weight-zero] v11, SAME two zero-allocation arms: packed path filters both out entirely (empty bucket set) -> not bucketed", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-106", + "version": 11, + "variations": [ + {"id": "A", "traffic_allocation": 0, "status": "running"}, + {"id": "B", "traffic_allocation": 0, "status": "stopped"} + ], + "expected": null + } +] diff --git a/Tests/ConvertSwiftSDKCoreTests/Preview/PreviewDecisionTests.swift b/Tests/ConvertSwiftSDKCoreTests/Preview/PreviewDecisionTests.swift new file mode 100644 index 0000000..434a79d --- /dev/null +++ b/Tests/ConvertSwiftSDKCoreTests/Preview/PreviewDecisionTests.swift @@ -0,0 +1,345 @@ +// Tests/ConvertSwiftSDKCoreTests/Preview/PreviewDecisionTests.swift +// RED-phase suite for `PreviewDecision` (qs-02 Experiment Preview, contract §2 — "Decision" +// clause / AC4 / AC5), task IOS-3. +// +// `PreviewDecision` does NOT exist yet (Sources/ConvertSwiftSDKCore/Preview/PreviewDecision.swift +// is unwritten). This file MUST fail to compile against the missing `PreviewDecision` symbol — +// that is the expected, correct RED state. The vectors below pin the contract the to-be-built +// GREEN-phase primitive has to satisfy. +// +// SCOPE (IOS-3 — narrower than the full qs-02 preview surface): this is the pure Core-only +// decision-matching primitive, isolated from config fetch, `ConvertContext`, and tracking. Its +// signature takes ONLY `(experience, variationId)` — no `visitorId`, no decision-store input. +// Because sticky decisions and the bucketing hash are not reachable inputs, the "bypass stored +// decisions / the bucketing hash" half of contract §2's Decision clause is a STRUCTURAL, +// type-signature-level guarantee here, not a runtime check — this file cannot (and does not +// attempt to) construct a "visitor with a different persisted sticky decision" scenario, because +// the primitive has no visitorId/store parameter to seed one. That AC5 sub-case is exercised at +// the `ConvertContext` layer in a later task (IOS-5), where the preview-aware call site sits in +// front of `DecisionStore`/`BucketingManager` and is proven to never reach either. +// +// Contract pinned by this suite (GREEN-phase implementation must satisfy exactly): +// `public enum PreviewDecision { public static func forcedVariation( +// for experience: Components.Schemas.ConfigExperience, +// variationId: String +// ) -> Variation? }` +// in `Sources/ConvertSwiftSDKCore/Preview/PreviewDecision.swift` (sibling to `PreviewParam`). +// +// - Matches `variationId` against `experience.variations` by `id` and returns a `Variation` +// in the SAME shape `BucketingManager.bucket`/`bucketVersionGated` build from a normal +// bucketed decision (verified at `Sources/ConvertSwiftSDKCore/Bucketing/BucketingManager.swift:118-123` +// and `:205-210`): `Variation(id: matched.id ?? "", key: matched.key ?? "", experienceId: +// experience.id, experienceKey: experience.key)` — EXCEPT `experience.key`/`experience.id`, +// which (qs-02 Fix 3) is inert-on-bad-input rather than degraded: a nil OR empty +// `experience.key`/`experience.id` returns `nil`, the SAME signal as an unmatched +// `variationId`, instead of building a `Variation` with an empty `experienceKey` that would +// poison `ConvertContext.runExperiences`' sibling filter and could never be matched by +// `runExperience`'s `experienceKey == key` short-circuit. +// - MUST bypass experience status/environment and variation status/traffic by construction: +// the signature has no attributes/environment/locationProperties parameter for a rule gate +// to consult, and no visitorId/hash-seed input for a bucketing walk to run — status/traffic/ +// environment fields on the fixtures below are set to actively-hostile values (draft, paused, +// mismatched environment, stopped, zero-traffic) specifically to prove the match is untouched +// by them, not merely "unset". +// - MUST NOT call `BucketingManager` or `ExperienceManager.selectVariation` (a GREEN-phase +// implementation constraint — not independently testable here without spy infrastructure, +// which is out of scope for this primitive; the structural signature above is the enforcement +// mechanism: neither collaborator is reachable without a visitorId/store to pass them). +// - Returns `nil` when `variationId` is not present in `experience.variations`, OR +// `experience.key`/`experience.id` is nil/empty (inert-on-bad-input signal). The primitive +// itself must not log or throw — the warning-log + "context behaves fully normally" wiring +// around this `nil` is deferred to task IOS-5. +// +// SonarQube `new_duplicated_lines_density` (3% gate): every scenario rides ONE parameterized +// `@Test(arguments:)` over a single `forcedVariationCases` table, built from two shared +// `makeExperience`/`makeVariation` fixture builders (mirroring the `BucketingManagerTests` +// `makeExperience` precedent) — no test re-wires `Components.Schemas.ConfigExperience` inline. +// +// NOTE: `Variation` is `Codable & Sendable & Identifiable` but NOT `Equatable`, so `#expect(==)` +// cannot compare it directly (same situation `PreviewParamTests` hit with its parsed-pair tuple). +// Each case's expectation is expressed as an optional `ExpectedVariation` (a local, `Equatable`, +// `Sendable` value type); the actual `Variation` result is mapped into one before comparison. + +import Foundation +import Testing +@testable import ConvertSwiftSDKCore + +@Suite("PreviewDecision") +struct PreviewDecisionTests { + + /// Comparable stand-in for `Variation`'s four fields, since `Variation` does not conform to + /// `Equatable`. + struct ExpectedVariation: Equatable, Sendable { + let id: String + let key: String + let experienceId: String + let experienceKey: String + } + + /// One variation spec for ``makeExperience`` — deliberately allows hostile + /// status/traffic values (non-running, zero-traffic) so the fixtures can prove the + /// primitive never consults them. Mirrors the `VariationSpec` precedent in + /// `BucketingManagerTests`. + private struct VariationSpec: Sendable { + let id: String + let key: String + let trafficAllocation: Double + let status: Components.Schemas.VariationStatuses + } + + /// One forced-decision test vector. + struct ForcedVariationCase: Sendable { + let description: String + let experience: Components.Schemas.ConfigExperience + let variationId: String + let expected: ExpectedVariation? + } + + // MARK: - Shared fixture builders (SonarQube 3% gate — declared once, reused per case) + + /// Builds a ``Components.Schemas.ConfigExperience`` with the given `id`/`key`/`status`/ + /// `environment` and a `variations` list assembled from ``VariationSpec`` entries — relying + /// on the generated memberwise inits (unlisted fields default to `nil`). `status` and + /// `environment` are exposed as knobs so a case can set them to values that would fail a + /// normal decision (draft/paused status, a mismatched environment) while still expecting the + /// forced variation back. + private static func makeExperience( + id: String?, + key: String?, + status: Components.Schemas.ExperienceStatuses? = nil, + environment: String? = nil, + variations: [VariationSpec] + ) -> Components.Schemas.ConfigExperience { + let configs = variations.map { spec in + Components.Schemas.ExperienceVariationConfig( + id: spec.id, + key: spec.key, + traffic_allocation: spec.trafficAllocation, + status: spec.status + ) + } + return Components.Schemas.ConfigExperience( + id: id, + key: key, + status: status, + variations: configs, + environment: environment + ) + } + + // MARK: - Case table + + /// Covers AC4 (forced decision) and the status/environment/traffic bypass half of AC5 (the + /// sticky-decision sub-case is deferred to IOS-5 per the file-header note). Each hostile-state + /// case targets exactly one experience/variation, set up so a NORMAL decision pipeline + /// (`ExperienceManager.selectVariation` / `BucketingManager`) would refuse or never reach it — + /// `forcedVariation` must return it anyway, in the standard `Variation` shape. + static let forcedVariationCases: [ForcedVariationCase] = [ + // --- AC5: draft-status experience — status bypass --- + ForcedVariationCase( + description: "draft-status experience still forces the requested variation", + experience: makeExperience( + id: "exp-draft", + key: "exp-draft-key", + status: .draft, + variations: [ + VariationSpec(id: "var-1", key: "control", trafficAllocation: 100, status: .running), + VariationSpec(id: "var-2", key: "treatment", trafficAllocation: 100, status: .running) + ] + ), + variationId: "var-2", + expected: ExpectedVariation( + id: "var-2", key: "treatment", experienceId: "exp-draft", experienceKey: "exp-draft-key" + ) + ), + + // --- AC5: paused experience — status bypass --- + ForcedVariationCase( + description: "paused experience still forces the requested variation", + experience: makeExperience( + id: "exp-paused", + key: "exp-paused-key", + status: .paused, + variations: [ + VariationSpec(id: "var-1", key: "control", trafficAllocation: 100, status: .running) + ] + ), + variationId: "var-1", + expected: ExpectedVariation( + id: "var-1", key: "control", experienceId: "exp-paused", experienceKey: "exp-paused-key" + ) + ), + + // --- AC5: mismatched environment — no environment parameter exists to gate on --- + ForcedVariationCase( + description: "experience with a mismatched environment still forces the requested variation", + experience: makeExperience( + id: "exp-env", + key: "exp-env-key", + status: .active, + environment: "staging-only", + variations: [ + VariationSpec(id: "var-1", key: "control", trafficAllocation: 100, status: .running) + ] + ), + variationId: "var-1", + expected: ExpectedVariation( + id: "var-1", key: "control", experienceId: "exp-env", experienceKey: "exp-env-key" + ) + ), + + // --- AC5: non-running (stopped) variation — variation-status bypass --- + ForcedVariationCase( + description: "a stopped (non-running) target variation is still forced", + experience: makeExperience( + id: "exp-stopped", + key: "exp-stopped-key", + status: .active, + variations: [ + VariationSpec(id: "var-1", key: "control", trafficAllocation: 50, status: .stopped) + ] + ), + variationId: "var-1", + expected: ExpectedVariation( + id: "var-1", key: "control", experienceId: "exp-stopped", experienceKey: "exp-stopped-key" + ) + ), + + // --- AC5: zero-traffic target variation — traffic-filter bypass --- + ForcedVariationCase( + description: "a zero-traffic target variation is still forced", + experience: makeExperience( + id: "exp-zero-traffic", + key: "exp-zero-traffic-key", + status: .active, + variations: [ + VariationSpec(id: "var-1", key: "control", trafficAllocation: 0, status: .running) + ] + ), + variationId: "var-1", + expected: ExpectedVariation( + id: "var-1", + key: "control", + experienceId: "exp-zero-traffic", + experienceKey: "exp-zero-traffic-key" + ) + ), + + // --- AC4/AC5 combined: every hostile condition stacked on the SAME experience/variation --- + ForcedVariationCase( + description: "draft status + mismatched environment + non-running + zero-traffic, all " + + "stacked, still force the requested variation (full-bypass proof)", + experience: makeExperience( + id: "exp-combo", + key: "exp-combo-key", + status: .draft, + environment: "staging-only", + variations: [ + VariationSpec(id: "var-1", key: "control", trafficAllocation: 0, status: .stopped) + ] + ), + variationId: "var-1", + expected: ExpectedVariation( + id: "var-1", key: "control", experienceId: "exp-combo", experienceKey: "exp-combo-key" + ) + ), + + // --- inert-on-bad-input: unknown variationId -> nil --- + ForcedVariationCase( + description: "unknown variationId not present in the experience's variations -> nil", + experience: makeExperience( + id: "exp-unknown", + key: "exp-unknown-key", + status: .active, + variations: [ + VariationSpec(id: "var-1", key: "control", trafficAllocation: 100, status: .running) + ] + ), + variationId: "does-not-exist", + expected: nil + ), + + // --- inert-on-bad-input (qs-02 Fix 3): nil experience.key -> nil, even on a matching + // variationId — a `Variation` with an empty `experienceKey` would poison + // `runExperiences`' sibling filter and could never be matched by `runExperience`'s + // `experienceKey == key` short-circuit. --- + ForcedVariationCase( + description: "nil experience.key with an otherwise-matching variationId -> nil", + experience: makeExperience( + id: "exp-nil-key", + key: nil, + status: .active, + variations: [ + VariationSpec(id: "var-1", key: "control", trafficAllocation: 100, status: .running) + ] + ), + variationId: "var-1", + expected: nil + ), + + // --- inert-on-bad-input (qs-02 Fix 3): empty experience.key -> nil (same as nil). --- + ForcedVariationCase( + description: "empty experience.key with an otherwise-matching variationId -> nil", + experience: makeExperience( + id: "exp-empty-key", + key: "", + status: .active, + variations: [ + VariationSpec(id: "var-1", key: "control", trafficAllocation: 100, status: .running) + ] + ), + variationId: "var-1", + expected: nil + ), + + // --- inert-on-bad-input (qs-02 Fix 3): nil experience.id -> nil, even on a matching + // variationId and a non-empty key. --- + ForcedVariationCase( + description: "nil experience.id with an otherwise-matching variationId -> nil", + experience: makeExperience( + id: nil, + key: "exp-nil-id-key", + status: .active, + variations: [ + VariationSpec(id: "var-1", key: "control", trafficAllocation: 100, status: .running) + ] + ), + variationId: "var-1", + expected: nil + ), + + // --- inert-on-bad-input (qs-02 Fix 3): empty experience.id -> nil (same as nil). --- + ForcedVariationCase( + description: "empty experience.id with an otherwise-matching variationId -> nil", + experience: makeExperience( + id: "", + key: "exp-empty-id-key", + status: .active, + variations: [ + VariationSpec(id: "var-1", key: "control", trafficAllocation: 100, status: .running) + ] + ), + variationId: "var-1", + expected: nil + ) + ] + + @Test("forcedVariation", arguments: forcedVariationCases) + func forcedVariation(_ caseUnderTest: ForcedVariationCase) { + let result = PreviewDecision.forcedVariation( + for: caseUnderTest.experience, + variationId: caseUnderTest.variationId + ) + let actual = result.map { + ExpectedVariation( + id: $0.id, key: $0.key, experienceId: $0.experienceId, experienceKey: $0.experienceKey + ) + } + let got = String(describing: actual) + let want = String(describing: caseUnderTest.expected) + #expect( + actual == caseUnderTest.expected, + "\(caseUnderTest.description): got \(got), expected \(want)" + ) + } +} diff --git a/Tests/ConvertSwiftSDKCoreTests/Preview/PreviewParamTests.swift b/Tests/ConvertSwiftSDKCoreTests/Preview/PreviewParamTests.swift new file mode 100644 index 0000000..f9f3206 --- /dev/null +++ b/Tests/ConvertSwiftSDKCoreTests/Preview/PreviewParamTests.swift @@ -0,0 +1,128 @@ +// Tests/ConvertSwiftSDKCoreTests/Preview/PreviewParamTests.swift +// Pure-parse PARITY SUITE for `PreviewParam.parse(_:)` (qs-02 Experiment Preview — AC9). +// +// RED phase (TDD): `PreviewParam` does NOT exist yet (Sources/ConvertSwiftSDKCore/Preview/ +// PreviewParam.swift is unwritten). This file MUST fail to compile against the missing +// `PreviewParam` symbol — that is the expected, correct RED state. The vectors below define +// the contract the to-be-built implementation has to satisfy. +// +// SPEC (qs-02-experiment-preview.md, contract §2 + AC9): the canonical link param is +// `convert_preview={experienceId}.{variationId}` — dot-separated NUMERIC-ID strings, mirroring +// the web force-param `_conv_eforce={expId}.{varId}`. `"123.456"` -> `(experienceId: "123", +// variationId: "456")`. Malformed input -> `nil`: no dot, empty side, non-numeric side, more +// than one dot, trailing/leading dot, empty string, whitespace-only. +// +// SonarQube `new_duplicated_lines_density` (3% gate): ALL parse coverage rides ONE +// parameterized @Test over a single `parseCases` table — never one @Test per malformed +// category, which would be near-identical duplicated bodies. +// +// NOTE: `PreviewParam.parse` returns a named tuple `(experienceId: String, variationId: +// String)?`, and Swift tuples are not `Equatable`, so `#expect(==)` cannot compare them +// directly. Each case's expectation is expressed as an optional `ParsedPair` (a local, +// `Equatable`, `Sendable` value type); the actual tuple result is mapped into a `ParsedPair` +// before comparison. + +import Foundation +import Testing +@testable import ConvertSwiftSDKCore + +@Suite("PreviewParam") +struct PreviewParamTests { + + /// Comparable stand-in for the `(experienceId: String, variationId: String)` tuple that + /// `PreviewParam.parse` returns, since tuples are not `Equatable`. + struct ParsedPair: Equatable, Sendable { + let experienceId: String + let variationId: String + } + + /// One parse vector. A pure value type so swift-testing can pass it through `arguments:`. + struct ParseCase: Sendable { + let input: String + let expected: ParsedPair? + let description: String + } + + /// Covers the valid canonical case plus every malformed category named in AC9 / contract §2. + static let parseCases: [ParseCase] = [ + // --- valid: dot-separated numeric-id strings --- + ParseCase( + input: "123.456", + expected: ParsedPair(experienceId: "123", variationId: "456"), + description: "canonical numeric pair parses to (experienceId, variationId)" + ), + + // --- no dot --- + ParseCase( + input: "123456", + expected: nil, + description: "no dot separator -> nil" + ), + + // --- empty side --- + ParseCase( + input: ".456", + expected: nil, + description: "empty experienceId side (single leading dot) -> nil" + ), + ParseCase( + input: "123.", + expected: nil, + description: "empty variationId side (single trailing dot) -> nil" + ), + + // --- non-numeric side --- + ParseCase( + input: "abc.456", + expected: nil, + description: "non-numeric experienceId side -> nil" + ), + ParseCase( + input: "123.abc", + expected: nil, + description: "non-numeric variationId side -> nil" + ), + + // --- more than one dot (three non-empty components) --- + ParseCase( + input: "123.456.789", + expected: nil, + description: "more than one dot with non-empty components -> nil" + ), + + // --- trailing/leading dot (extra dot producing an empty extra component) --- + ParseCase( + input: "123.456.", + expected: nil, + description: "trailing dot after a valid pair -> nil" + ), + ParseCase( + input: ".123.456", + expected: nil, + description: "leading dot before a valid pair -> nil" + ), + + // --- empty string --- + ParseCase( + input: "", + expected: nil, + description: "empty string -> nil" + ), + + // --- whitespace-only --- + ParseCase( + input: " ", + expected: nil, + description: "whitespace-only string -> nil" + ) + ] + + @Test("parse", arguments: parseCases) + func parse(_ caseUnderTest: ParseCase) { + let result = PreviewParam.parse(caseUnderTest.input) + let resultPair = result.map { ParsedPair(experienceId: $0.experienceId, variationId: $0.variationId) } + let got = String(describing: resultPair) + let want = String(describing: caseUnderTest.expected) + #expect(resultPair == caseUnderTest.expected, "\(caseUnderTest.description): got \(got), expected \(want)") + } +} diff --git a/Tests/ConvertSwiftSDKCoreTests/Rules/MutualExclusionGenericRegressionTests.swift b/Tests/ConvertSwiftSDKCoreTests/Rules/MutualExclusionGenericRegressionTests.swift new file mode 100644 index 0000000..781c7a2 --- /dev/null +++ b/Tests/ConvertSwiftSDKCoreTests/Rules/MutualExclusionGenericRegressionTests.swift @@ -0,0 +1,145 @@ +// Tests/ConvertSwiftSDKCoreTests/Rules/MutualExclusionGenericRegressionTests.swift +// +// RED-phase suite for IOS-2 (M2 unit, iOS mutual-exclusion qs-03) — AC7 ("generic-rule regression +// lock"): adding the JSON-sentinel `RuleAdapter.flatten(_ sentinelRuleTree: JSONValue)` overload (see +// the sibling `MutualExclusionJSONFlattenTests.swift` for its assumed shape/rationale — +// not re-derived here) must NOT change how a GENERIC leaf resolves through the EXISTING typed path, +// and the NEW JSON-sentinel path must produce the SAME `RuleCondition`s the typed path already does +// for the SAME wire leaf — "reuse, do not fork, the OR/AND/OR_WHEN semantics" (qs-03 AC7 wording). +// Spec of record: +// _bmad-output/implementation-artifacts/2026-06-09-convert-ios-sdk/qs-04-mutual-exclusion-rule.md +// Task/plan: work/2026-07-15-ios-sdk-mutual-exclusion/workflow-state.yaml (task IOS-2, "v18s"). +// (Citation path corrected: ai-driven-product-dev#86 — qs-03 was renumbered/relocated to qs-04 under +// implementation-artifacts/ after this suite was authored. No behavioral change: this suite tests +// `RuleAdapter.flatten` typed-vs-JSON-sentinel equivalence, which the qs-04 whole-audience-override +// rework (see the sibling `MutualExclusionRuleManagerTests.swift` header) does not touch.) +// +// ── Isolation rationale (per the IOS-2 dispatch) ────────────────────────────────────────────────── +// Kept as its OWN file, deliberately separate from `MutualExclusionJSONFlattenTests.swift` +// (which proves a STATEFUL leaf survives ALONGSIDE a generic one) and from +// `MutualExclusionRuleManagerTests.swift` (which proves `RuleManager`'s resolver seam) — so a +// regression in the bit-identical generic-path contract shows up as its own isolated failure, not +// entangled with the new stateful-leaf assertions. +// +// This file depends on the SAME not-yet-existing symbol as its sibling — +// `RuleAdapter.flatten(_ sentinelRuleTree: JSONValue) -> [RuleGroup]` — and so MUST fail to COMPILE +// today ("type 'RuleAdapter' has no member 'flatten'" for a `JSONValue` argument), the expected, +// correct RED state; as with the sibling suites, this breaks `swift build`/`swift test` for the WHOLE +// `ConvertSwiftSDKCoreTests` target until GREEN lands (unavoidable in a single-SPM-module target). +// +// ── Method ───────────────────────────────────────────────────────────────────────────────────────── +// For each of 3 generic families (a text family via `city`, the `country` family, and the `bool` +// family via `is_desktop` — the exact three families the IOS-2 dispatch names), the SAME leaf JSON is +// decoded TWO ways: once through the EXISTING typed `Components.Schemas.RuleObjectAudience` decoder +// (`RuleAdapter.flatten(_ audience:)`), once through the NEW `JSONValue` decoder +// (`RuleAdapter.flatten(_ sentinelRuleTree:)`) — and the two `[RuleGroup]` results are asserted +// EQUAL. `RuleGroup`/`RuleCondition` are both `Equatable` (structural, synthesized), so this is a +// direct bit-identical comparison, not a field-by-field manual check that could itself drift. +// +// SonarQube `new_duplicated_lines_density` guard: ONE parameterized `@Test(arguments:)` drives all 3 +// families from a single `genericLeafCases` table + two shared decode helpers — no per-family test +// function duplication. + +import Foundation +import Testing +@testable import ConvertSwiftSDKCore + +@Suite("RuleAdapter generic-rule regression lock: typed vs JSON-sentinel flatten — IOS-2 AC7 RED") +struct MutualExclusionGenericRegressionTests { + + /// One generic-family leaf JSON literal under test, reusing the EXACT shapes already proven + /// end-to-end by `RuleAdapterTests` (city/`contains`, country/`equals`, is_desktop/`equals`) so + /// this suite carries no risk of a fixture typo silently producing a false-negative "these agree" + /// result. + struct GenericLeafCase: Sendable { + let description: String + let leafJSON: String + } + + static let genericLeafCases: [GenericLeafCase] = [ + GenericLeafCase( + description: "text family (city, match_type contains)", + leafJSON: #"{ "rule_type": "city", "value": "NY", "matching": { "match_type": "contains" } }"# + ), + GenericLeafCase( + description: "country family (match_type equals)", + leafJSON: #"{ "rule_type": "country", "value": "US", "matching": { "match_type": "equals" } }"# + ), + GenericLeafCase( + description: "bool family (is_desktop, match_type equals)", + leafJSON: #"{ "rule_type": "is_desktop", "value": true, "matching": { "match_type": "equals" } }"# + ), + // AC7 coverage gap (decision-audit finding 2): the three cases above are all NAMED + // families (matched off `rule_type` itself). A `generic_*_key_value` family — matched off + // the leaf's EXPLICIT `key` sibling (`value3.key` typed / top-level `key` JSON, per + // `RuleAdapter.condition(fromTextKeyValue:)` and the JSON path's `keyValueRuleTypes` + // routing) — was NOT exercised through this typed-vs-JSON equivalence lock. Real fullstack + // configs serve these key-value families, and a degraded audience (carrying the new + // `bucketed_into_experience_key` leaf) can carry a `generic_text_key_value` sibling in the + // SAME tree, so it flows through the JSON path too. + GenericLeafCase( + description: "generic text key-value family (explicit key \"browser\", match_type contains)", + leafJSON: #"{ "rule_type": "generic_text_key_value", "key": "browser", "value": "chrome", "# + + #""matching": { "match_type": "contains" } }"# + ), + // AC7 convergence lock (code-review R1 fix, corrected in R2): `bucketed_into_experience` is a + // VALID `RuleElementAudience` discriminator (`BoolMatchRulesTypes.bucketed_into_experience` — + // ConfigSchemas.swift) decoded by the SAME `GenericBoolMatchRule` struct as `is_desktop` + // above, but it is UNMAPPED on BOTH paths: the typed switch + // `RuleAdapter.condition(fromAudienceLeaf:)` deliberately does not route it to + // `condition(fromBool:)` (`RuleAdapter.swift`'s `fromBool` doc comment: "the stateful + // `bucketed_into_experience` is ALSO `GenericBoolMatchRule` but is deliberately NOT routed + // here") and falls to `default: degraded()`; the JSON-sentinel path's + // `condition(fromSentinelLeaf:)` (`RuleAdapter+JSONSentinelFlatten.swift`) has a + // `namedFamilyRuleTypes` allowlist that deliberately EXCLUDES `bucketed_into_experience` for + // the same reason, so its `guard namedFamilyRuleTypes.contains(ruleType) else { return + // degraded() }` degrades this leaf too instead of routing it through `make(...)` with the + // leaf's real `matching.negated`/`match_type`. Both paths therefore degrade to the identical + // `(key "", matchType "", value nil, negation false)` — this leaf's `negated: true` does NOT + // survive on either path. This vector's purpose is to LOCK that convergence: it guards + // against re-introducing the R1 over-cover bug where the JSON path evaluated unmapped + // families live (using their real `negated`/`match_type` instead of degrading them), which + // would silently re-diverge this case from the typed path. + GenericLeafCase( + description: "bucketed_into_experience (GenericBoolMatchRule, unmapped in typed switch, negated true)", + leafJSON: #"{ "rule_type": "bucketed_into_experience", "value": true, "# + + #""matching": { "match_type": "equals", "negated": true } }"# + ) + ] + + /// Decodes `leavesJSON` through the EXISTING typed `RuleObjectAudience` path (the same envelope + /// `RuleAdapterTests.makeAudienceRules` uses). + private func decodeTyped(orWhenLeaves leavesJSON: String) throws -> Components.Schemas.RuleObjectAudience { + let envelope = """ + { "OR": [ { "AND": [ { "OR_WHEN": [ \(leavesJSON) ] } ] } ] } + """ + return try JSONDecoder().decode( + Components.Schemas.RuleObjectAudience.self, + from: Data(envelope.utf8) + ) + } + + /// Decodes the SAME `leavesJSON` through the NEW `JSONValue` sentinel path. + private func decodeSentinelRuleTree(orWhenLeaves leavesJSON: String) throws -> JSONValue { + let envelope = """ + { "OR": [ { "AND": [ { "OR_WHEN": [ \(leavesJSON) ] } ] } ] } + """ + return try JSONDecoder().decode(JSONValue.self, from: Data(envelope.utf8)) + } + + /// AC7: for every generic family, the typed-path and JSON-sentinel-path flatten to EQUAL + /// `[RuleGroup]` — the new JSON-walk must reuse, not fork, the per-leaf extraction and the + /// OR/AND/OR_WHEN collapse. + @Test( + "typed-path and JSON-sentinel-path flatten produce bit-identical RuleGroups per generic family", + arguments: genericLeafCases + ) + func typedAndJSONPathsAgree(_ testCase: GenericLeafCase) throws { + let typedGroups = RuleAdapter.flatten(try decodeTyped(orWhenLeaves: testCase.leafJSON)) + let jsonGroups = RuleAdapter.flatten(try decodeSentinelRuleTree(orWhenLeaves: testCase.leafJSON)) + #expect( + typedGroups == jsonGroups, + "\(testCase.description): typed-path and JSON-sentinel-path must produce identical RuleGroups" + ) + } +} diff --git a/Tests/ConvertSwiftSDKCoreTests/Rules/MutualExclusionJSONFlattenTests.swift b/Tests/ConvertSwiftSDKCoreTests/Rules/MutualExclusionJSONFlattenTests.swift new file mode 100644 index 0000000..3dc3384 --- /dev/null +++ b/Tests/ConvertSwiftSDKCoreTests/Rules/MutualExclusionJSONFlattenTests.swift @@ -0,0 +1,187 @@ +// Tests/ConvertSwiftSDKCoreTests/Rules/MutualExclusionJSONFlattenTests.swift +// +// RED-phase suite for IOS-2 (M2 unit, iOS mutual-exclusion qs-03): `RuleAdapter` gains a path to +// flatten a rule tree FROM the sentinel `JSONValue` an audience degrades to when its rule tree embeds +// an unrecognised `rule_type` leaf (IOS-1's `ProjectConfig.degradedAudienceSentinels`, already +// committed — see `Sources/ConvertSwiftSDKCore/Data/ProjectConfig.swift`, +// `ProjectConfig+AudienceDecoding.swift`). Spec of record: +// _bmad-output/implementation-artifacts/2026-06-09-convert-ios-sdk/qs-04-mutual-exclusion-rule.md +// Task/plan: work/2026-07-15-ios-sdk-mutual-exclusion/workflow-state.yaml (task IOS-2, "v18s"). +// (Citation path corrected: ai-driven-product-dev#86 — qs-03 was renumbered/relocated to qs-04 under +// implementation-artifacts/ after this suite was authored. No behavioral change: this suite tests +// `RuleAdapter.flatten(_ sentinelRuleTree:)` detection output (a stateful leaf surviving alongside a +// generic sibling), which the qs-04 whole-audience-override rework — see the sibling +// `MutualExclusionRuleManagerTests.swift` header — does not touch; only the RESOLUTION of an already- +// detected stateful leaf moved out of `RuleManager`.) +// +// ── Why this must exist (consequence of IOS-1) ──────────────────────────────────────────────────── +// A `bucketed_into_experience_key` leaf makes the WHOLE audience fail the generated typed decode +// (`unknownOneOfDiscriminator`), so IOS-1 sentinel-captures the ENTIRE degraded audience's raw JSON +// (id/key/name/rules — via `SentinelWrapped`) into +// `ProjectConfig.degradedAudienceSentinels[id]`, rather than a typed `RuleObjectAudience`. The +// existing `RuleAdapter.flatten(_ audience: Components.Schemas.RuleObjectAudience)` overload can +// never see that audience's rule tree — it only ever runs against a TYPED graph, and a degraded +// audience's `rules` typed property is `nil` by construction (see +// `ProjectConfig+AudienceDecoding.swift`'s `reconstructAudience(fromSentinelPayload:)`). So the +// leaf's `rule_type`/`value`/`matching.negated` — and any generic sibling leaf in the SAME degraded +// tree — are only reachable by walking the CAPTURED `JSONValue` directly. +// +// None of the symbols this file exercises exist in Sources/ yet: +// - `RuleAdapter.flatten(_ sentinelRuleTree: JSONValue) -> [RuleGroup]` (new overload) +// - `RuleCondition.statefulTarget` / `StatefulRuleTarget` (see the sibling +// `MutualExclusionRuleManagerTests.swift` header for the full assumed shape — not re-derived here) +// This file MUST fail to COMPILE ("type 'RuleAdapter' has no member 'flatten'" for a `JSONValue` +// argument / "cannot find 'StatefulRuleTarget' in scope") — the expected, correct RED state, and (as +// with the sibling suite) breaks `swift build`/`swift test` for the WHOLE `ConvertSwiftSDKCoreTests` +// target until GREEN lands — unavoidable in a single-SPM-module target, matching the accepted RED +// shape of IOS-1's own suites. +// +// ── ASSUMED SHAPE (spec-silent — IOS-2 implementation choice) ──────────────────────────────────── +// extension RuleAdapter { +// static func flatten(_ sentinelRuleTree: JSONValue) -> [RuleGroup] +// } +// Chosen as an OVERLOAD of the EXISTING `flatten(_:)` name (dispatched by parameter TYPE), symmetric +// with the two typed overloads already present (`flatten(_ audience: RuleObjectAudience)` / +// `flatten(_ location: RuleObject)` — `RuleAdapter.swift` lines 49/65). The parameter is the "rules" +// SUB-TREE node — i.e. the JSON shape `{"OR": [...]}` — NOT the whole audience object: this mirrors +// the typed overloads exactly, which likewise take `RuleObjectAudience`/`RuleObject` (themselves the +// `rules` sub-tree's typed shape, per `ConfigAudience.rulesPayload.value1: RuleObjectAudience`, +// `ConfigSchemas.swift:46-64`), not the enclosing `ConfigAudience`. A caller sitting on +// `ProjectConfig.degradedAudienceSentinels[id]` (the FULL audience payload) is expected to navigate +// to the `"rules"` member itself before calling `RuleAdapter.flatten(_:)` — that one-hop navigation +// is a future (IOS-3) consumer's job, not re-tested here; this suite feeds the rules sub-tree +// directly, exactly as `RuleAdapterTests`'s existing typed-path tests feed `RuleObjectAudience` +// directly rather than a whole `ConfigAudience`. +// +// Extraction contract this suite pins: +// - A leaf whose `rule_type == "bucketed_into_experience_key"` maps to a `RuleCondition` whose +// `statefulTarget` is non-nil: `ruleType` = the leaf's `rule_type` string, `targetExperienceKey` +// = the leaf's `value` string, and `negation` = the leaf's `matching.negated` (defaulting to +// `false` when absent, mirroring the typed-path default at `RuleAdapter.make(...)`). +// - Every OTHER (generic) leaf in the SAME JSON tree maps to the SAME `RuleCondition` the TYPED +// path already produces for that leaf shape (AC7 parity) — see the sibling +// `MutualExclusionGenericRegressionTests.swift` for the dedicated bit-identical regression lock; +// THIS file only proves generic leaves survive ALONGSIDE a stateful leaf in one mixed tree, not +// the full typed/JSON equivalence (kept isolated per the IOS-2 dispatch, so the two concerns +// don't get entangled in one suite). +// - The OR -> AND -> OR_WHEN collapse is IDENTICAL to the typed path: one `RuleGroup` per AND-block, +// that block's `OR_WHEN` leaves collected into `conditions` — reusing, not forking, the +// collapsing semantics (AC7's "reuse, do not fork the OR/AND/OR_WHEN semantics"). +// +// SonarQube `new_duplicated_lines_density` guard: ONE shared `makeSentinelRuleTree` envelope helper +// (mirroring `RuleAdapterTests.makeAudienceRules`, decoding to `JSONValue` instead of the typed +// `RuleObjectAudience`) — no test repeats the `OR -> AND -> OR_WHEN` envelope literal. + +import Foundation +import Testing +@testable import ConvertSwiftSDKCore + +@Suite("RuleAdapter JSON-sentinel flatten (bucketed_into_experience_key) — IOS-2 RED") +struct MutualExclusionJSONFlattenTests { + + // MARK: - Fixture factory (single decode site — SonarQube duplication guard) + + /// Wraps caller-supplied OR_WHEN leaf-array JSON in the fixed `OR -> AND -> OR_WHEN` envelope + /// (verified against `Components.Schemas.RuleObjectAudience`, `ConfigSchemas.swift:3613-3667`, + /// and reused verbatim from `RuleAdapterTests.makeAudienceRules`'s envelope literal) and decodes + /// it as a raw `JSONValue` tree instead of the typed `RuleObjectAudience` — the shape a + /// degraded audience's sentinel-captured `rules` sub-tree has. + private func makeSentinelRuleTree(orWhenLeaves leavesJSON: String) throws -> JSONValue { + let envelope = """ + { "OR": [ { "AND": [ { "OR_WHEN": [ \(leavesJSON) ] } ] } ] } + """ + return try JSONDecoder().decode(JSONValue.self, from: Data(envelope.utf8)) + } + + // MARK: - Mixed stateful + generic leaf, one AND-block + + /// A `bucketed_into_experience_key` leaf and a generic `city` leaf sharing ONE OR_WHEN array + /// flatten to exactly one `RuleGroup` carrying two conditions: one stateful, one plain — proving + /// the new leaf family survives ALONGSIDE an existing family in the same degraded tree, and that + /// the OR->AND->OR_WHEN collapse is untouched (still one group per AND-block). + @Test("flatten(JSONValue): a stateful leaf + a generic leaf in one AND-block -> one group, two conditions") + func flattenMixedStatefulAndGenericLeaf() throws { + let ruleTree = try makeSentinelRuleTree(orWhenLeaves: """ + { "rule_type": "bucketed_into_experience_key", "value": "exp-a", \ + "matching": { "match_type": "equals", "negated": true } }, + { "rule_type": "city", "value": "NYC", "matching": { "match_type": "matches" } } + """) + + let groups = RuleAdapter.flatten(ruleTree) + #expect(groups.count == 1, "one AND-block must flatten to exactly one RuleGroup") + let conditions = groups.first?.conditions ?? [] + #expect(conditions.count == 2, "both OR_WHEN leaves must survive as conditions") + + let stateful = try #require( + conditions.first { $0.statefulTarget != nil }, + "the bucketed_into_experience_key leaf must produce a condition with a non-nil statefulTarget" + ) + #expect( + stateful.statefulTarget?.ruleType == "bucketed_into_experience_key", + "statefulTarget.ruleType must carry the leaf's rule_type discriminator" + ) + #expect( + stateful.statefulTarget?.targetExperienceKey == "exp-a", + "statefulTarget.targetExperienceKey must carry the leaf's value (the target experience key)" + ) + #expect(stateful.negation == true, "matching.negated (true) must map onto RuleCondition.negation") + + let generic = try #require( + conditions.first { $0.statefulTarget == nil }, + "the city leaf must still produce a plain (non-stateful) condition" + ) + #expect(generic.key == "city") + #expect(generic.value == "NYC") + #expect(generic.matchType == "matches") + #expect(generic.negation == false, "the city leaf omits matching.negated, defaulting to false") + } + + // MARK: - Lone stateful leaf + + /// A lone `bucketed_into_experience_key` leaf (no sibling) still flattens to one group / one + /// condition, with `matching.negated == false` correctly reflected (not defaulted true by + /// accident) and no attribute-lookup fields populated. + @Test("flatten(JSONValue): a lone stateful leaf survives with negation == false intact") + func flattenLoneStatefulLeafNonNegated() throws { + let ruleTree = try makeSentinelRuleTree(orWhenLeaves: """ + { "rule_type": "bucketed_into_experience_key", "value": "exp-b", \ + "matching": { "match_type": "equals", "negated": false } } + """) + + let groups = RuleAdapter.flatten(ruleTree) + #expect(groups.count == 1) + let condition = try #require(groups.first?.conditions.first) + #expect(condition.statefulTarget?.ruleType == "bucketed_into_experience_key") + #expect(condition.statefulTarget?.targetExperienceKey == "exp-b") + #expect(condition.negation == false) + } + + // MARK: - Two AND-blocks (OR of stateful-only vs generic-only), collapse fidelity + + /// Two SEPARATE AND-blocks — one holding only the stateful leaf, one holding only a generic + /// leaf — flatten to TWO `RuleGroup`s (the outer-OR), each with exactly one condition. Pins that + /// the JSON-path collapse does not merge sibling AND-blocks the way it must not merge sibling + /// OR_WHEN leaves into extra groups either. + @Test("flatten(JSONValue): two AND-blocks (stateful-only, generic-only) -> two groups") + func flattenTwoSeparateAndBlocks() throws { + let envelope = """ + { "OR": [ + { "AND": [ { "OR_WHEN": [ + { "rule_type": "bucketed_into_experience_key", "value": "exp-a", \ + "matching": { "match_type": "equals", "negated": false } } + ] } ] }, + { "AND": [ { "OR_WHEN": [ + { "rule_type": "country", "value": "US", "matching": { "match_type": "equals" } } + ] } ] } + ] } + """ + let ruleTree = try JSONDecoder().decode(JSONValue.self, from: Data(envelope.utf8)) + + let groups = RuleAdapter.flatten(ruleTree) + #expect(groups.count == 2, "two AND-blocks under the outer OR must flatten to two RuleGroups") + let statefulGroups = groups.filter { $0.conditions.contains { $0.statefulTarget != nil } } + let genericGroups = groups.filter { $0.conditions.contains { $0.statefulTarget == nil } } + #expect(statefulGroups.count == 1, "exactly one group must hold the stateful-only leaf") + #expect(genericGroups.count == 1, "exactly one group must hold the generic-only leaf") + } +} diff --git a/Tests/ConvertSwiftSDKCoreTests/Rules/MutualExclusionRuleManagerTests.swift b/Tests/ConvertSwiftSDKCoreTests/Rules/MutualExclusionRuleManagerTests.swift new file mode 100644 index 0000000..16e2955 --- /dev/null +++ b/Tests/ConvertSwiftSDKCoreTests/Rules/MutualExclusionRuleManagerTests.swift @@ -0,0 +1,213 @@ +// Tests/ConvertSwiftSDKCoreTests/Rules/MutualExclusionRuleManagerTests.swift +// +// RED-phase suite for M2 (iOS mutual-exclusion qs-04): the WHOLE-AUDIENCE-OVERRIDE resolution seam +// that resolves a `bucketed_into_experience_key` rule leaf. Spec of record: +// _bmad-output/implementation-artifacts/2026-06-09-convert-ios-sdk/qs-04-mutual-exclusion-rule.md +// Task/plan: work/2026-07-15-ios-sdk-mutual-exclusion/workflow-state.yaml. +// +// ── RE-ARCHITECTURE: the resolver seam moves OUT of `RuleManager` ────────────────────────────── +// The PRIOR design (this suite, pre-rework) threaded a `resolvingBucketedIntoExperienceKey` +// resolver INTO `RuleManager.evaluate(rules:against:resolvingBucketedIntoExperienceKey:)`, letting a +// stateful leaf be mixed with generic leaves inside the SAME `RuleGroup` (AND) or across sibling +// groups (OR) and resolved by the SAME generic rule engine. That does NOT correspond to any real JS +// code path: the JS reference (`javascript-sdk/packages/data/src/data-manager.ts`, +// `feat/mutual-exclusion-rule`) resolves mutual exclusion at the WHOLE-AUDIENCE level — +// `_isBucketingExclusionRule` (:1246-1265) walks ONE audience's OR→AND→OR_WHEN tree for ANY +// `bucketed_into_experience_key` leaf; if found, `filterMatchedRecordsWithRule` (:1336-1345) resolves +// the ENTIRE audience's match via `_resolveBucketingExclusion` (:1282-1304) ALONE — sibling leaves in +// the SAME audience tree are NEVER evaluated, and the generic rule engine (`RuleManager`-equivalent, +// `isRuleMatched`) is never even called for that audience. This rework moves the resolution to a +// dedicated, `RuleManager`-independent seam so iOS mirrors that whole-audience-override shape; see +// `MutualExclusionFixtures.swift`'s header for the two-audience (`matching_options`) composition this +// enables at the `ExperienceManager` layer (`MutualExclusionExperienceManagerTests.swift`, AC6). +// +// Consequently: +// - `RuleManager.evaluate` DROPS its `resolvingBucketedIntoExperienceKey` parameter entirely — the +// generic engine never resolves a stateful leaf again (it is bypassed at the audience level, +// never handed a stateful-leaf-carrying `RuleGroup`). +// - The 8-row fixture (AC1) and the "known target logs no warning" check (AC8) are RE-POINTED to +// the NEW resolution seam below instead of `RuleManager.evaluate(... +// resolvingBucketedIntoExperienceKey:)` — all 8 rows and their expected `matched`/warn outcomes +// are FROZEN VERBATIM (unchanged from the qs-04 fixture table). +// - The two prior "AC6-unit" tests (`andBlockCombinesStatefulAndGenericLeaves` / +// `orAcrossGroupsGenericCompensatesForFailingStatefulGroup`), which exercised a stateful leaf +// mixed with a generic leaf inside ONE `RuleGroup` fed to `RuleManager.evaluate`, are REMOVED — +// that composition no longer represents any reachable code path once resolution moves to the +// whole-audience level (an exclusion audience's sibling leaves, if any, are never evaluated by +// ANY engine). Real ALL/ANY combination is now covered at the `ExperienceManager` two-audience +// layer (`MutualExclusionExperienceManagerTests.swift`, AC6), not by mixing leaves in one +// `RuleGroup`. +// - `RuleCondition.statefulTarget` / `StatefulRuleTarget` are UNCHANGED and still populated by +// `RuleAdapter.flatten(_ sentinelRuleTree: JSONValue)` (see the sibling +// `MutualExclusionJSONFlattenTests.swift` / `MutualExclusionGenericRegressionTests.swift`, both +// unaffected by this rework) — they remain the DETECTION representation a whole-audience caller +// walks to find the exclusion leaf; only the RESOLUTION step (this file) moves out of +// `RuleManager`. +// +// ── ASSUMED SHAPE (spec-silent — a M2 implementation choice, recorded here and in the sibling +// decision log for the decision_audit checkpoint; mirrors JS's `_resolveBucketingExclusion` +// (`data-manager.ts:1282-1304`) almost verbatim) ────────────────────────────────────────────────── +// +// internal enum BucketingExclusion { +// /// Mirrors JS `_resolveBucketingExclusion`: `bucketedRaw = resolver(targetExperienceKey)` +// /// (`nil` == unknown target -> WARN naming the key, default `bucketedRaw = false`; a +// /// resolver-returned `false` == a KNOWN target the visitor is simply not bucketed into -> +// /// NO warn); `matched = negated ? !bucketedRaw : bucketedRaw`, applied exactly once. +// static func resolve( +// targetExperienceKey: String, +// negated: Bool, +// resolver: (String) -> Bool?, +// logger: Logger +// ) -> Bool +// } +// +// This is a NEW type/file (`Sources/ConvertSwiftSDKCore/Rules/BucketingExclusion.swift`, assumed — +// not yet created), so this file MUST FAIL TO COMPILE today ("cannot find 'BucketingExclusion' in +// scope") — the expected, correct RED state; as with the IOS-1/IOS-2 precedent this breaks +// `swift build`/`swift test` for the WHOLE `ConvertSwiftSDKCoreTests` target (single SPM module) +// until GREEN lands the symbol. +// +// Fixture: qs-04's inline 8-row table (spec lines 70-79), FROZEN VERBATIM from the pre-rework suite — +// only the call site (which seam resolves each row) changed, never the rows or expected outcomes. +// Config context: `exp-a` (id `100111`), `exp-b` (id `100222`) both exist; `exp-zz` does NOT (rows +// 6/7 — the resolver returns `nil` for it). At THIS (unit) level there is no real +// `DecisionStore`/`ExperienceManager` — the "stored bucketing map" column is simulated purely by the +// FAKE resolver's return value per row (a real config/storage resolver is the `ExperienceManager` +// integration layer's job, `MutualExclusionExperienceManagerTests.swift`). Visitor `attributes` are +// irrelevant here — proving AC4 ("zero new application inputs") structurally: the new seam has no +// `attributes` parameter at all, so it cannot consult them even in principle. +// +// SonarQube `new_duplicated_lines_density` guard: ONE parameterized `@Test(arguments:)` drives all +// 8 rows from a single `fixtureRows` table + one shared `MutualExclusionFixtureRow` model — no +// per-row test-function duplication. + +import Testing +@testable import ConvertSwiftSDKCore + +@Suite("Bucketing-exclusion whole-audience resolution (bucketed_into_experience_key) — RED") +struct MutualExclusionRuleManagerTests { + + // MARK: - Fixture row model (shared — SonarQube duplication guard) + + /// One row of the qs-04 inline 8-row fixture (spec lines 70-79), collapsed to the fields the + /// UNIT-level resolver seam actually needs: the target key, the rule's `negated` flag, what the + /// FAKE resolver returns for that key (`nil` == "unknown target"), and the expected outcomes. + struct MutualExclusionFixtureRow: Sendable { + /// The qs-04 spec's row number (1-8), carried through purely for readable failure messages. + let rowNumber: Int + /// `rule.value` — the target experience KEY the leaf names. + let targetExperienceKey: String + /// The leaf's `matching.negated` flag. + let negation: Bool + /// What the fake three-state resolver returns when queried for `targetExperienceKey`: + /// `true` (bucketed), `false` (known target, not bucketed), or `nil` (unknown target). + let resolverReturns: Bool? + /// The qs-04 fixture's expected `matched` column. + let expectedMatched: Bool + /// Whether a `.warn` line naming `targetExperienceKey` must be logged (only rows 6/7 — + /// `resolverReturns == nil`, an unknown target; AC8). + let expectsWarning: Bool + } + + /// The qs-04 8-row fixture (spec lines 70-79), FROZEN VERBATIM from the pre-rework suite — rows + /// and expected `matched`/warn outcomes are unchanged; only the resolution seam under test moved + /// (see this file's header). Row 8 is DELIBERATELY identical in resolver-stance/expectation to + /// row 4 at THIS unit level — the spec's row-8 scenario ("present only in persisted storage, + /// fresh SDK instance, warm files") is a cross-relaunch integration concern this seam cannot + /// distinguish from row 4 without a real `DecisionStore`; that distinction is the + /// `ExperienceManager` integration layer's job (out of scope here). + static let fixtureRows: [MutualExclusionFixtureRow] = [ + MutualExclusionFixtureRow( + rowNumber: 1, targetExperienceKey: "exp-a", negation: false, + resolverReturns: false, expectedMatched: false, expectsWarning: false + ), + MutualExclusionFixtureRow( + rowNumber: 2, targetExperienceKey: "exp-a", negation: true, + resolverReturns: false, expectedMatched: true, expectsWarning: false + ), + MutualExclusionFixtureRow( + rowNumber: 3, targetExperienceKey: "exp-a", negation: false, + resolverReturns: true, expectedMatched: true, expectsWarning: false + ), + MutualExclusionFixtureRow( + rowNumber: 4, targetExperienceKey: "exp-a", negation: true, + resolverReturns: true, expectedMatched: false, expectsWarning: false + ), + MutualExclusionFixtureRow( + rowNumber: 5, targetExperienceKey: "exp-a", negation: true, + resolverReturns: false, expectedMatched: true, expectsWarning: false + ), + MutualExclusionFixtureRow( + rowNumber: 6, targetExperienceKey: "exp-zz", negation: false, + resolverReturns: nil, expectedMatched: false, expectsWarning: true + ), + MutualExclusionFixtureRow( + rowNumber: 7, targetExperienceKey: "exp-zz", negation: true, + resolverReturns: nil, expectedMatched: true, expectsWarning: true + ), + MutualExclusionFixtureRow( + rowNumber: 8, targetExperienceKey: "exp-a", negation: true, + resolverReturns: true, expectedMatched: false, expectsWarning: false + ) + ] + + /// Builds the fake three-state resolver for `row` — returns `row.resolverReturns` when queried + /// for `row.targetExperienceKey`, `nil` for any other key (defensive; never exercised here since + /// every row queries exactly one key). Centralized so no test body re-inlines the closure + /// (SonarQube 3% guard). + private func fakeResolver(for row: MutualExclusionFixtureRow) -> (String) -> Bool? { + { key in key == row.targetExperienceKey ? row.resolverReturns : nil } + } + + // MARK: - AC1 + AC8 — the 8-row fixture, table-driven, against the NEW whole-audience seam + + /// Drives all 8 rows through the NEW `BucketingExclusion.resolve` seam with a FAKE three-state + /// resolver, asserting BOTH the `matched` column (AC1) and the warn-only-on-unknown-target + /// behavior (AC8) per row — the seam has no `attributes` parameter at all, proving AC4 + /// structurally (there is nothing for the stateful leaf to look up even in principle). + @Test("mutual-exclusion 8-row fixture: matched + warn-on-unknown-target", arguments: fixtureRows) + func fixtureRow(_ row: MutualExclusionFixtureRow) { + let logger = MockLogger() + + let matched = BucketingExclusion.resolve( + targetExperienceKey: row.targetExperienceKey, + negated: row.negation, + resolver: fakeResolver(for: row), + logger: logger + ) + #expect( + matched == row.expectedMatched, + "row \(row.rowNumber): expected matched == \(row.expectedMatched), got \(matched)" + ) + + let warnedNamingTarget = logger.entries().contains { + $0.level == .warn && $0.message.contains(row.targetExperienceKey) + } + #expect( + warnedNamingTarget == row.expectsWarning, + "row \(row.rowNumber): expected warning-naming-'\(row.targetExperienceKey)' == \(row.expectsWarning)" + ) + } + + /// AC8, isolated and stricter than the per-row check above: rows 1 and 5 (a KNOWN target the + /// visitor is simply not bucketed into) must log NO warning AT ALL — not just no warning + /// mentioning the key, but zero log lines of any kind from this evaluation. + @Test( + "AC8: known-target resolution (rows 1 & 5) logs no warning whatsoever", + arguments: [fixtureRows[0], fixtureRows[4]] + ) + func knownTargetLogsNoWarningAtAll(_ row: MutualExclusionFixtureRow) { + let logger = MockLogger() + + _ = BucketingExclusion.resolve( + targetExperienceKey: row.targetExperienceKey, + negated: row.negation, + resolver: fakeResolver(for: row), + logger: logger + ) + #expect( + logger.entries().isEmpty, + "row \(row.rowNumber): a KNOWN target (resolver returned false, not nil) must log nothing" + ) + } +} diff --git a/Tests/ConvertSwiftSDKCoreTests/Support/MutualExclusionFixtures.swift b/Tests/ConvertSwiftSDKCoreTests/Support/MutualExclusionFixtures.swift new file mode 100644 index 0000000..2265940 --- /dev/null +++ b/Tests/ConvertSwiftSDKCoreTests/Support/MutualExclusionFixtures.swift @@ -0,0 +1,184 @@ +// Tests/ConvertSwiftSDKCoreTests/Support/MutualExclusionFixtures.swift +// Shared `ProjectConfig` / JSON builders for the mutual-exclusion end-to-end suite +// (M2, iOS mutual-exclusion qs-04). Spec of record: +// _bmad-output/implementation-artifacts/2026-06-09-convert-ios-sdk/qs-04-mutual-exclusion-rule.md +// +// ── Why a SEPARATE file from `ProjectConfigFixtures.swift` ──────────────────────────────── +// `ProjectConfigFixtures.swift` is already ~370 lines — appending here would push it toward +// SwiftLint's `file_length` gate. Mirrors the `ProjectConfig+AudienceDecoding.swift` / +// `RuleAdapter+JSONSentinelFlatten.swift` split precedent: a fresh file for a cohesive, +// self-contained set of builders rather than growing an already-large one. +// `ProjectConfigFixtures.experienceJSON` / `.makeConfig` (both plain `static func`, no `private`, +// so module-visible) are REUSED, not re-derived; ditto `ProjectConfigFixtures.audienceJSON` for the +// generic (`country`) audience half of the two-audience fixture below. +// +// ── RE-ARCHITECTURE (this file) — JS parity over the mixed-single-audience shape ────────────── +// The JS reference (`javascript-sdk/packages/data/src/data-manager.ts`, `feat/mutual-exclusion-rule`) +// resolves mutual exclusion at the WHOLE-AUDIENCE level: `_isBucketingExclusionRule` (:1246-1265) +// walks ONE audience's OR→AND→OR_WHEN tree for ANY `bucketed_into_experience_key` leaf; if found, the +// ENTIRE audience is an EXCLUSION audience and `filterMatchedRecordsWithRule` (:1336-1345) resolves its +// match via `_resolveBucketingExclusion` (:1282-1304) ALONE — sibling leaves in the SAME audience tree +// are NEVER evaluated. Cross-audience `ALL`/`ANY` combination (:418-442) then composes PER-AUDIENCE +// match booleans via `experience.settings.matching_options.audiences`. +// +// The PRIOR iOS fixture shape (`allOfRulesJSON`/`anyOfRulesJSON`, now REMOVED) built ONE audience whose +// rule tree mixed a stateful leaf with a generic sibling leaf, combined via ordinary AND/OR — that +// shape does not correspond to any real JS code path (a mixed exclusion-audience tree never reaches +// the generic engine at all; its sibling leaves are ignored by `_resolveBucketingExclusion`). This +// file now builds the JS-faithful TWO-AUDIENCE shape instead: one DEDICATED exclusion audience (the +// stateful leaf ALONE) and one separate GENERIC audience, composed via the experience's +// `settings.matching_options.audiences` (`ALL`/`ANY`) — see `twoAudienceMutualExclusionConfig(_:)`. +// +// ── What the single-audience builders below still build ─────────────────────────────────── +// A DEGRADED audience (its `rules` tree embeds the unrecognised `bucketed_into_experience_key` +// `rule_type`, so the whole audience fails the generated typed decode and is retained as a +// placeholder + sentinel-captured raw JSON in `ProjectConfig.degradedAudienceSentinels`) attached to a +// SECOND experience — used by the pure-exclusion (AC2/AC3/AC5/AC8) scenarios, where the audience +// carries ONLY the stateful leaf (`singleLeafRulesJSON(_:)`). + +import Foundation +@testable import ConvertSwiftSDKCore + +/// Pure namespace (mirrors `ProjectConfigFixtures`) — every member is `static`. +enum MutualExclusionFixtures { + + /// The `bucketed_into_experience_key` leaf JSON targeting `targetExperienceKey`, with + /// `matching.negated` set per `negated`. This `rule_type` is NOT in the generated + /// `RuleElementAudience` oneOf, so any audience embedding it degrades to a sentinel-captured + /// placeholder at decode — never a typed `RuleObjectAudience`. + static func statefulLeafJSON(targetExperienceKey: String, negated: Bool) -> String { + """ + {"rule_type":"bucketed_into_experience_key","value":"\(targetExperienceKey)",\ + "matching":{"match_type":"equals","negated":\(negated)}} + """ + } + + /// A generic `country == equals` leaf — the SAME known shape `ProjectConfigFixtures + /// .audienceJSON` already proves round-trips through `RuleAdapter`/`Comparisons`. + static func countryLeafJSON(equals: String) -> String { + """ + {"rule_type":"country","value":"\(equals)","matching":{"match_type":"equals"}} + """ + } + + /// Wraps ONE leaf JSON literal in the sole `OR → AND → OR_WHEN` envelope — the wire shape a + /// DEDICATED single-rule audience carries (JS `_isBucketingExclusionRule`'s whole-audience + /// exclusion shape: an audience whose ENTIRE tree is the one stateful leaf, no sibling). Distinct + /// from the removed `allOfRulesJSON`/`anyOfRulesJSON` (which combined MULTIPLE leaves within ONE + /// audience's tree — the mixed-single-audience shape this rework replaces with two SEPARATE + /// audiences composed via `matching_options`, see `twoAudienceMutualExclusionConfig(_:)`). + static func singleLeafRulesJSON(_ leafJSON: String) -> String { + "{\"OR\":[{\"AND\":[{\"OR_WHEN\":[\(leafJSON)]}]}]}" + } + + /// A `ConfigAudience` JSON object whose `rules` is the caller-supplied tree. Carrying the + /// stateful leaf makes this audience degrade to a sentinel-captured placeholder at decode — a + /// real read-only resolver reads the leaf back off `ProjectConfig.degradedAudienceSentinels[id]`'s + /// `"rules"` member. + static func degradedAudienceJSON(id: String, key: String, rulesJSON: String) -> String { + """ + {"id":"\(id)","key":"\(key)","type":"transient","rules":\(rulesJSON)} + """ + } + + /// A two-experience `ProjectConfig`: `expAKey` (always buckets, no gates — the mutual-exclusion + /// TARGET) and `expBKey` (gated on ONE degraded audience carrying `audienceRulesJSON` — the + /// experience carrying the exclusion rule ALONE, no generic sibling). Mirrors qs-04's inline + /// fixture context (`exp-a`, `exp-b`, both always-active, sole full-traffic variation) with + /// test-local ids/keys. `audienceRulesJSON` is expected to come from + /// ``singleLeafRulesJSON(_:)`` over ``statefulLeafJSON(targetExperienceKey:negated:)`` (a PURE + /// exclusion audience — no `ALL`/`ANY` combination question arises with a single audience). + /// + /// - Parameters: + /// - expAId: `exp-a`'s wire `id` (what the bucketing map is keyed on — iOS is id-keyed). + /// - expAKey: `exp-a`'s wire `key` (what a `bucketed_into_experience_key` rule's `value` + /// targets, and what `fullExperience(forKey:)` resolves). + /// - expBId / expBKey / expBVariationId: mirror the above for the gated experience. + /// - audienceId: the degraded audience's wire `id`, referenced from `exp-b`'s `audiences`. + /// - audienceRulesJSON: the audience's `rules` tree body. + static func twoExperienceMutualExclusionConfig( + expAId: String = "id-a", + expAKey: String = "exp-a", + expAVariationId: String = "var-a", + expBId: String = "id-b", + expBKey: String = "exp-b", + expBVariationId: String = "var-b", + audienceId: String = "aud-me", + audienceRulesJSON: String + ) throws -> ProjectConfig { + let experienceA = ProjectConfigFixtures.experienceJSON( + id: expAId, key: expAKey, variationId: expAVariationId, variationKey: "control-a", alloc: 100 + ) + let experienceB = ProjectConfigFixtures.experienceJSON( + id: expBId, key: expBKey, variationId: expBVariationId, variationKey: "control-b", alloc: 100, + audiences: [audienceId] + ) + let audience = degradedAudienceJSON( + id: audienceId, key: "\(audienceId)-key", rulesJSON: audienceRulesJSON + ) + return try ProjectConfigFixtures.makeConfig( + experiencesJSON: "[\(experienceA),\(experienceB)]", + audiencesJSON: "[\(audience)]" + ) + } + + /// A two-experience, TWO-AUDIENCE `ProjectConfig` mirroring JS's real composition shape (spec + /// verified fact: "Fullstack audiences are transient; `matching_options.audiences` supports + /// `ALL`/`ANY`"; JS `matchRulesByField`, `data-manager.ts:419-428`): `expBKey` is gated on TWO + /// SEPARATE attached audiences — + /// (a) a DEDICATED exclusion audience (`exclusionAudienceId`) whose tree is ONLY the negated + /// `bucketed_into_experience_key` leaf targeting `exclusionTargetKey` (no generic sibling — + /// JS's `_resolveBucketingExclusion` never sees one anyway), and + /// (b) a GENERIC audience (`genericAudienceId`) carrying a `country == genericCountryEquals` + /// leaf (`ProjectConfigFixtures.audienceJSON`, reused verbatim — no new leaf shape) — + /// composed via `expBKey`'s `settings.matching_options.audiences = matchingOptions` (`"all"` / + /// `"any"`, the raw `GenericListMatchingOptions` wire value). + /// + /// - Parameters: + /// - expAId/expAKey/expAVariationId: the mutual-exclusion TARGET experience (always buckets, + /// no gates). + /// - expBId/expBKey/expBVariationId: the gated experience carrying both audiences. + /// - exclusionAudienceId/exclusionTargetKey/exclusionNegated: the dedicated exclusion audience's + /// id and its lone leaf's target key / `negated` flag. + /// - genericAudienceId/genericCountryEquals: the generic audience's id and its `country` leaf's + /// match value. + /// - matchingOptions: the raw `settings.matching_options.audiences` wire value (`"all"` / + /// `"any"`) `expBKey` emits. + static func twoAudienceMutualExclusionConfig( + expAId: String = "id-a", + expAKey: String = "exp-a", + expAVariationId: String = "var-a", + expBId: String = "id-b", + expBKey: String = "exp-b", + expBVariationId: String = "var-b", + exclusionAudienceId: String = "aud-excl", + exclusionTargetKey: String = "exp-a", + exclusionNegated: Bool = true, + genericAudienceId: String = "aud-generic", + genericCountryEquals: String = "US", + matchingOptions: String + ) throws -> ProjectConfig { + let experienceA = ProjectConfigFixtures.experienceJSON( + id: expAId, key: expAKey, variationId: expAVariationId, variationKey: "control-a", alloc: 100 + ) + let experienceB = ProjectConfigFixtures.experienceJSON( + id: expBId, key: expBKey, variationId: expBVariationId, variationKey: "control-b", alloc: 100, + audiences: [exclusionAudienceId, genericAudienceId], + matchingOptionsAudiences: matchingOptions + ) + let exclusionAudience = degradedAudienceJSON( + id: exclusionAudienceId, + key: "\(exclusionAudienceId)-key", + rulesJSON: singleLeafRulesJSON( + statefulLeafJSON(targetExperienceKey: exclusionTargetKey, negated: exclusionNegated) + ) + ) + let genericAudience = ProjectConfigFixtures.audienceJSON( + id: genericAudienceId, key: "\(genericAudienceId)-key", countryEquals: genericCountryEquals + ) + return try ProjectConfigFixtures.makeConfig( + experiencesJSON: "[\(experienceA),\(experienceB)]", + audiencesJSON: "[\(exclusionAudience),\(genericAudience)]" + ) + } +} diff --git a/Tests/ConvertSwiftSDKCoreTests/Support/ProjectConfigFixtures.swift b/Tests/ConvertSwiftSDKCoreTests/Support/ProjectConfigFixtures.swift index 3ae7977..88a91b6 100644 --- a/Tests/ConvertSwiftSDKCoreTests/Support/ProjectConfigFixtures.swift +++ b/Tests/ConvertSwiftSDKCoreTests/Support/ProjectConfigFixtures.swift @@ -76,6 +76,11 @@ enum ProjectConfigFixtures { /// - alloc: The variation's 0–100 traffic percentage. /// - audiences: Audience ID strings to gate on (empty ⇒ audience gate bypassed/unrestricted). /// - locations: Location ID strings to gate on (empty ⇒ location gate bypassed/unrestricted). + /// - matchingOptionsAudiences: Emits `settings.matching_options.audiences` as this raw wire value + /// (`"all"` / `"any"`, per `Components.Schemas.GenericListMatchingOptions`) when non-`nil`. `nil` + /// (the default) omits the `settings` object entirely — byte-identical to every pre-existing + /// caller's output (qs-04 mutual-exclusion two-audience rework; the multi-audience ALL/ANY + /// composition `matching_options` drives is exercised by `MutualExclusionExperienceManagerTests`). static func experienceJSON( id: String, key: String, @@ -83,14 +88,18 @@ enum ProjectConfigFixtures { variationKey: String, alloc: Int, audiences: [String] = [], - locations: [String] = [] + locations: [String] = [], + matchingOptionsAudiences: String? = nil ) -> String { - """ + let settingsFragment = matchingOptionsAudiences.map { + ",\"settings\":{\"matching_options\":{\"audiences\":\"\($0)\"}}" + } ?? "" + return """ {"id":"\(id)","key":"\(key)","type":"a/b",\ "audiences":\(idArrayJSON(audiences)),\ "locations":\(idArrayJSON(locations)),\ "variations":[{"id":"\(variationId)","key":"\(variationKey)",\ - "traffic_allocation":\(alloc)}]} + "traffic_allocation":\(alloc)}]\(settingsFragment)} """ } diff --git a/Tests/ConvertSwiftSDKCoreTests/ToLoggableTests.swift b/Tests/ConvertSwiftSDKCoreTests/ToLoggableTests.swift index f2b8f65..47001a6 100644 --- a/Tests/ConvertSwiftSDKCoreTests/ToLoggableTests.swift +++ b/Tests/ConvertSwiftSDKCoreTests/ToLoggableTests.swift @@ -40,4 +40,44 @@ struct ToLoggableTests { #expect(!masked.contains("supersecret")) #expect(masked.contains("x=1")) } + + /// `debug_token` boundary-anchoring cases (qs-02 Fix 2): a lookalike param name is left + /// untouched, while a real `debug_token=` is fully stripped (name included) whether it + /// sits right after `?`, right after `&`, or at the very start of the string with no leading + /// `?`/`&` at all. + static let debugTokenBoundaryCases: [RedactionCase] = [ + // Lookalike param `x_debug_token` must NOT be over-stripped — the whole pair survives + // verbatim, and no ellipsis is introduced anywhere in the string. + RedactionCase( + input: "GET https://api.convert.com/v1/config?x_debug_token=secret&y=1", + allowed: "x_debug_token=secret", + forbidden: "\u{2026}" + ), + // Bare-start (no leading `?`/`&`): the real param is still fully stripped, name included. + RedactionCase(input: "debug_token=secret&y=1", allowed: "y=1", forbidden: "debug_token="), + // Bare-start: the token VALUE must not leak either (a miss here is a credential leak). + RedactionCase(input: "debug_token=secret&y=1", allowed: "y=1", forbidden: "secret"), + // `?`-prefixed: the real param is still fully stripped, name included. + RedactionCase( + input: "https://api.convert.com/v1/config?debug_token=secret&y=1", + allowed: "y=1", + forbidden: "debug_token=" + ), + // `?`-prefixed: the token VALUE must not leak either. + RedactionCase( + input: "https://api.convert.com/v1/config?debug_token=secret&y=1", + allowed: "y=1", + forbidden: "secret" + ) + ] + + @Test( + "debug_token is boundary-anchored: lookalikes survive, real tokens are fully stripped", + arguments: debugTokenBoundaryCases + ) + func anchorsDebugTokenBoundary(_ testCase: RedactionCase) { + let masked = toLoggable(testCase.input) + #expect(masked.contains(testCase.allowed)) + #expect(!masked.contains(testCase.forbidden)) + } } diff --git a/Tests/ConvertSwiftSDKTests/Adapters/URLSessionEventUploaderTests.swift b/Tests/ConvertSwiftSDKTests/Adapters/URLSessionEventUploaderTests.swift new file mode 100644 index 0000000..8ee1504 --- /dev/null +++ b/Tests/ConvertSwiftSDKTests/Adapters/URLSessionEventUploaderTests.swift @@ -0,0 +1,75 @@ +// Tests/ConvertSwiftSDKTests/Adapters/URLSessionEventUploaderTests.swift +// +// AC3 regression lock (qs-02 IOS-1 — token hygiene). `URLSessionEventUploader.upload(_:)` +// (`Sources/ConvertSwiftSDK/Adapters/URLSessionEventUploader.swift`) POSTs to +// `"{trackEndpoint}/track/{sdkKey}"` with `headers: [:]` and NEVER receives a +// `ConvertConfiguration` (or a `debugToken`) at all — there is structurally no path for a debug +// token to reach a track request. This test is EXPECTED TO PASS TODAY: no production changes are +// needed for it to go green. It locks in a currently-true structural invariant so a future change +// cannot silently introduce a debug-token leak on the track path, unlike the other two files in +// this qs-02 IOS-1 RED batch (`ConfigFetchServiceTests.swift`, +// `DebugTokenRedactionTests.swift`), which fail to compile pending `ConvertConfiguration.debugToken`. +// +// Uses `MockHTTPClient` (NOT `URLProtocolStub`) — `URLSessionEventUploader` is exercised entirely +// through the `HTTPClient` port, so no process-global stub is needed and this suite is +// parallel-safe (no nesting under `URLProtocolStubBackedTests`). +import Testing +import Foundation +@testable import ConvertSwiftSDK + +@Suite("URLSessionEventUploader token hygiene") +struct URLSessionEventUploaderTests { + /// The event-delivery base URL the uploader POSTs under (no trailing slash). + private static let trackEndpoint = "https://track.test/api/v1" + /// The project SDK key that scopes the delivery route. + private static let sdkKey = "fc-key" + + /// Builds a 200 `HTTPURLResponse` for the shared track URL. + private func okResponse(for url: URL) -> HTTPURLResponse { + guard let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: [:] + ) else { + preconditionFailure("HTTPURLResponse(statusCode:200) is non-failing for a valid URL") + } + return response + } + + /// AC3 regression lock. Uploads a drained batch through the uploader over a `MockHTTPClient` + /// canned with a 200, then asserts the ONE recorded request carries: no query items at all + /// (`URLComponents(url:).queryItems` nil/empty), no `"debug_token"` substring anywhere in the + /// absolute URL string, and no header key or value containing `"debug_token"`. + @Test("upload's POST carries no debug_token in its URL or headers") + func uploadCarriesNoDebugTokenInURLOrHeaders() async throws { + guard let trackURL = URL(string: "\(Self.trackEndpoint)/track/\(Self.sdkKey)") else { + Issue.record("Failed to construct track URL") + return + } + let httpClient = MockHTTPClient(response: (Data(), okResponse(for: trackURL))) + let uploader = URLSessionEventUploader( + httpClient: httpClient, + trackEndpoint: Self.trackEndpoint, + sdkKey: Self.sdkKey + ) + let batch = makeTrackingBatch( + events: [], + visitorId: "visitor-1", + accountId: "acc-1", + projectId: "p-1" + ) + + try await uploader.upload([batch]) + + let requests = await httpClient.requests + let request = try #require(requests.first, "the uploader issued no request to the transport") + let components = URLComponents(url: request.url, resolvingAgainstBaseURL: false) + #expect(components?.queryItems == nil || components?.queryItems?.isEmpty == true) + #expect(!request.url.absoluteString.contains("debug_token")) + let headerLeaksDebugToken = request.headers.contains { key, value in + key.contains("debug_token") || value.contains("debug_token") + } + #expect(!headerLeaksDebugToken) + } +} diff --git a/Tests/ConvertSwiftSDKTests/ConfigFetchServiceDebugTokenTests.swift b/Tests/ConvertSwiftSDKTests/ConfigFetchServiceDebugTokenTests.swift new file mode 100644 index 0000000..0d043c5 --- /dev/null +++ b/Tests/ConvertSwiftSDKTests/ConfigFetchServiceDebugTokenTests.swift @@ -0,0 +1,188 @@ +// Tests/ConvertSwiftSDKTests/ConfigFetchServiceDebugTokenTests.swift +// +// RED phase (qs-02 IOS-1, AC1/AC2 — debugToken transport + cache elimination). Split out of +// `ConfigFetchServiceTests.swift` as an `extension ConfigFetchServiceTests` (same module, same +// suite) purely to keep that file and its `type_body_length` under the project's SwiftLint +// limits — this is NOT a separate suite; swift-testing discovers `@Test` methods on a type across +// extensions in the same module, so these run as part of the `"ConfigFetchService"` `@Suite`. +// Reuses `ConfigFetchServiceTests`'s `makeSUT` / `SUT` / shared constants verbatim (both widened +// from `private` to internal there) rather than duplicating the wiring (SonarQube new-code +// duplication discipline). +// +// This file fails to compile until `ConvertConfiguration.debugToken` exists (GREEN, qs-02 IOS-1) +// — the same RED signal `ConfigFetchServiceTests.swift` now carries via its `makeSUT` call. +import Testing +import Foundation +@testable import ConvertSwiftSDK + +extension ConfigFetchServiceTests { + // MARK: - debugToken transport (qs-02 IOS-1, AC1) + + /// `buildConfigURL()` debugToken transport matrix (qs-02 IOS-1, AC1): when `debugToken` is + /// set, the URL carries `debug_token=` AND `_conv_low_cache=1` is FORCED regardless of + /// `networkCacheLevel`; when unset, `_conv_low_cache=1` follows `networkCacheLevel` exactly as + /// today. Asserted via query-item COUNTS (`.filter { }.count`), NOT + /// `Dictionary(uniqueKeysWithValues:)` — the sibling `buildConfigURLAppendsQueryItems` test uses + /// that helper, but on a genuine duplicate-key defect (e.g. `_conv_low_cache` emitted twice when + /// `debugToken` is set AND `networkCacheLevel == .low`) `Dictionary(uniqueKeysWithValues:)` would + /// `fatalError` the whole test process rather than fail one test — precisely the dedup defect + /// this table exists to catch safely. A SEPARATE test from `buildConfigURLAppendsQueryItems` for + /// that reason (not an extension of its existing table). + @Test( + "buildConfigURL debugToken transport: appends debug_token and forces/dedupes _conv_low_cache", + arguments: [ + ( + debugToken: String?.none, + cacheLevel: CacheLevel.normal, + expectedLowCacheCount: 0, + expectDebugToken: false + ), + ( + debugToken: String?.none, + cacheLevel: .low, + expectedLowCacheCount: 1, + expectDebugToken: false + ), + ( + debugToken: "qa-token", + cacheLevel: .normal, + expectedLowCacheCount: 1, + expectDebugToken: true + ), + ( + debugToken: "qa-token", + cacheLevel: .low, + expectedLowCacheCount: 1, + expectDebugToken: true + ) + ] + ) + func buildConfigURLDebugTokenTransport( + debugToken: String?, + cacheLevel: CacheLevel, + expectedLowCacheCount: Int, + expectDebugToken: Bool + ) throws { + let sut = makeSUT(cacheLevel: cacheLevel, debugToken: debugToken) + + let url = try sut.service.buildConfigURL() + + let components = try #require( + URLComponents(url: url, resolvingAgainstBaseURL: false), + "built URL is not decomposable into URLComponents" + ) + let queryItems = components.queryItems ?? [] + let lowCacheCount = queryItems.filter { $0.name == "_conv_low_cache" }.count + let debugTokenItems = queryItems.filter { $0.name == "debug_token" } + #expect(lowCacheCount == expectedLowCacheCount) + if expectDebugToken { + #expect(debugTokenItems.count == 1) + #expect(debugTokenItems.first?.value == debugToken) + } else { + #expect(debugTokenItems.isEmpty) + } + } + + /// AC1 wire proof — ALSO the complete coverage of the "scheduler refresh" clause of AC1. + /// `ConfigRefreshScheduler.performRefresh()` (`Sources/ConvertSwiftSDK/Lifecycle/ + /// ConfigRefreshScheduler.swift`, lines ~296-309) calls `fetchService.fetchLiveConfig()` + /// VERBATIM, with zero scheduler-specific branching. `ConvertSwiftSDK.swift` (lines ~319-367) + /// builds exactly ONE `ConfigFetchService` instance (`activeProvider`) and passes that SAME + /// instance to BOTH the initial fetch and the `ConfigRefreshScheduler`. There is no second + /// URL-building code path a scheduler refresh could exercise that this seam does not already + /// cover, so this ONE `fetchLiveConfig()` proof at the `ConfigFetchService` level IS the + /// complete coverage of "both the initial fetch and a scheduler refresh" — mirroring the + /// precedent `Tests/ConvertSwiftSDKTests/Integration/FullChainIntegrationTests.swift` (its + /// "AC1 REINTERPRETED" header) already set for a different story that hit the same + /// no-second-seam wall. + @Test("fetchLiveConfig issues a GET whose URL carries debug_token and exactly one _conv_low_cache") + func fetchLiveConfigRequestCarriesDebugTokenAndLowCache() async throws { + let sut = makeSUT(cacheLevel: .normal, httpResponse: Self.validConfigJSON, debugToken: "qa-token") + + _ = await sut.service.fetchLiveConfig() + + let requests = await sut.httpClient.requests + let request = try #require(requests.first, "the service issued no request to the transport") + let components = try #require( + URLComponents(url: request.url, resolvingAgainstBaseURL: false), + "built URL is not decomposable into URLComponents" + ) + let queryItems = components.queryItems ?? [] + #expect(queryItems.filter { $0.name == "debug_token" }.first?.value == "qa-token") + #expect(queryItems.filter { $0.name == "_conv_low_cache" }.count == 1) + } + + // MARK: - AC2: cache elimination + + /// AC2 (post-fetch write elimination, qs-02 IOS-1). With `debugToken` set, + /// `fetchLiveConfig()` must NOT write-through the response bytes to the disk cache at all — + /// the QA config must never persist. Table-driven over the boolean so the shared fetch + + /// read-back logic lives once (SonarQube new-code-duplication discipline): the `debugToken: + /// nil` case is a regression lock on the existing write-through contract (mirrors + /// `fetchLiveConfigWritesRawBytesToCache` in the sibling file), and the `debugToken` case + /// proves the skip by asserting the follow-up read THROWS (no file was ever created at + /// `cacheURL`). + @Test( + "fetchLiveConfig skips the disk write entirely when debugToken is set", + arguments: [ + (debugToken: String?.none, expectWrite: true), + (debugToken: "qa-token", expectWrite: false) + ] + ) + func fetchLiveConfigDebugTokenEliminatesWrite( + debugToken: String?, + expectWrite: Bool + ) async throws { + let sut = makeSUT(httpResponse: Self.validConfigJSON, debugToken: debugToken) + + let config = await sut.service.fetchLiveConfig() + + #expect(config != nil) + if expectWrite { + let onDisk = try await sut.fileStore.read(from: sut.cacheURL) + #expect(onDisk == Self.validConfigJSON) + } else { + await #expect(throws: (any Error).self) { + _ = try await sut.fileStore.read(from: sut.cacheURL) + } + } + } + + /// AC2 (cold-start read elimination, qs-02 IOS-1). With `debugToken` set, + /// `loadCachedConfig()` must NOT read the on-disk cache at all — the on-disk file is + /// pre-seeded with a KNOWN-VALID config (proving a real read would succeed), so a `nil` + /// result with `debugToken` set proves the read was skipped rather than merely failing. + /// + /// ── MockFileStore-vs-concrete-type discrepancy (verified) ──────────────────────────── + /// `ConfigFetchService.fileStore` is typed as the CONCRETE `CoordinatedFileStore` actor, not + /// `any FileStore` — so the protocol-conforming `MockFileStore` (`Support/MockPorts.swift`) + /// cannot be injected here. This test uses the same real-`CoordinatedFileStore` + unique-temp- + /// `cacheURL` approach `makeSUT` already wires for every other case in this suite; no new test + /// infrastructure is introduced. + @Test( + "loadCachedConfig skips the disk read entirely when debugToken is set", + arguments: [ + (debugToken: String?.none, expectCachedValueReturned: true), + (debugToken: "qa-token", expectCachedValueReturned: false) + ] + ) + func loadCachedConfigDebugTokenEliminatesRead( + debugToken: String?, + expectCachedValueReturned: Bool + ) async throws { + let sut = makeSUT(debugToken: debugToken) + try await sut.fileStore.write(Self.validConfigJSON, to: sut.cacheURL) + + let config = await sut.service.loadCachedConfig() + + if expectCachedValueReturned { + let decoded = try #require( + config, + "a pre-seeded valid cache file must decode to a non-nil config when debugToken is unset" + ) + #expect(decoded.accountId == Self.validAccountId) + } else { + #expect(config == nil) + } + } +} diff --git a/Tests/ConvertSwiftSDKTests/ConfigFetchServiceExperienceTests.swift b/Tests/ConvertSwiftSDKTests/ConfigFetchServiceExperienceTests.swift new file mode 100644 index 0000000..9df5363 --- /dev/null +++ b/Tests/ConvertSwiftSDKTests/ConfigFetchServiceExperienceTests.swift @@ -0,0 +1,125 @@ +// Tests/ConvertSwiftSDKTests/ConfigFetchServiceExperienceTests.swift +// +// RED phase (qs-02 IOS-4, AC8 — the `?exp=` fetch variant). Split out of +// `ConfigFetchServiceTests.swift` as an `extension ConfigFetchServiceTests` (same module, same +// suite) purely to keep that file and its `type_body_length` under the project's SwiftLint +// limits — this is NOT a separate suite; swift-testing discovers `@Test` methods on a type +// across extensions in the same module, so these run as part of the `"ConfigFetchService"` +// `@Suite`. Reuses `ConfigFetchServiceTests`'s `makeSUT` / `SUT` / shared constants verbatim +// (both already widened to internal for the sibling `ConfigFetchServiceDebugTokenTests.swift`) +// rather than duplicating the wiring (SonarQube new-code-duplication discipline). +// +// This file fails to compile until `ConfigFetchService.buildExperienceConfigURL(experienceId:)` +// and `ConfigFetchService.fetchExperienceConfig(experienceId:)` exist (GREEN, qs-02 IOS-4) — the +// same RED signal the sibling debug-token file carried for IOS-1. +// +// ── Contract under test (qs-02 §2 "Resolution") ──────────────────────────────────────────── +// The exp-fetch variant builds the config URL with `exp={experienceId}` appended, FORCES +// `_conv_low_cache=1` unconditionally (independent of `networkCacheLevel` — mirroring how a +// `debugToken` forces it on the normal path), and appends `debug_token=` when configured +// (reusing the IOS-1 logic). It must NEVER write-through to the on-disk config cache — the +// existing write-skip-when-`debugToken != nil` (IOS-1, `ConfigFetchService.swift:254`) is +// WIDENED on this path to apply UNCONDITIONALLY, regardless of whether `debugToken` is set, +// because a preview fetch must never persist a QA/stakeholder-only config as the ordinary +// cached config for subsequent launches. +import Testing +import Foundation +@testable import ConvertSwiftSDK + +extension ConfigFetchServiceTests { + // MARK: - buildExperienceConfigURL: query-item shape (qs-02 IOS-4) + + /// `buildExperienceConfigURL(experienceId:)` query-item matrix: `exp={id}` is always + /// present exactly once, `_conv_low_cache=1` is FORCED exactly once regardless of + /// `networkCacheLevel` (the exp path never consults it — unlike `buildConfigURL`, which + /// only forces it when a `debugToken` is set OR `networkCacheLevel == .low`), and + /// `debug_token={value}` is present iff a `debugToken` is configured. Parameterized over + /// the four `debugToken` × `cacheLevel` combinations so the build-and-inspect logic lives + /// once (SonarQube new-code-duplication discipline) instead of four near-identical bodies. + @Test( + "buildExperienceConfigURL appends exp + forced _conv_low_cache + debug_token when configured", + arguments: [ + (debugToken: String?.none, cacheLevel: CacheLevel.normal, expectDebugToken: false), + (debugToken: String?.none, cacheLevel: .low, expectDebugToken: false), + (debugToken: "qa-token", cacheLevel: .normal, expectDebugToken: true), + (debugToken: "qa-token", cacheLevel: .low, expectDebugToken: true) + ] + ) + func buildExperienceConfigURLAppendsQueryItems( + debugToken: String?, + cacheLevel: CacheLevel, + expectDebugToken: Bool + ) throws { + let sut = makeSUT(cacheLevel: cacheLevel, debugToken: debugToken) + + let url = try sut.service.buildExperienceConfigURL(experienceId: "555") + + let components = try #require( + URLComponents(url: url, resolvingAgainstBaseURL: false), + "built URL is not decomposable into URLComponents" + ) + let queryItems = components.queryItems ?? [] + let expItems = queryItems.filter { $0.name == "exp" } + let lowCacheItems = queryItems.filter { $0.name == "_conv_low_cache" } + #expect(expItems.count == 1) + #expect(expItems.first?.value == "555") + // FORCED exactly once on every combination — the exp path never consults cacheLevel. + #expect(lowCacheItems.count == 1) + let debugTokenItems = queryItems.filter { $0.name == "debug_token" } + if expectDebugToken { + #expect(debugTokenItems.count == 1) + #expect(debugTokenItems.first?.value == debugToken) + } else { + #expect(debugTokenItems.isEmpty) + } + } + + // MARK: - fetchExperienceConfig: decode + request shape + + /// `fetchExperienceConfig(experienceId:)` decodes a valid response into a `ProjectConfig` + /// AND the GET it issued to the transport carries `exp={id}` plus exactly one + /// `_conv_low_cache=1` — proving the built URL from `buildExperienceConfigURLAppendsQueryItems` + /// above is the one actually sent, not merely constructible. + @Test("fetchExperienceConfig decodes a valid response and issues a GET whose URL carries exp={id}") + func fetchExperienceConfigDecodesAndCarriesExpParam() async throws { + let sut = makeSUT(httpResponse: Self.validConfigJSON) + + let config = await sut.service.fetchExperienceConfig(experienceId: "777") + + let decoded = try #require(config, "a valid response must decode to a non-nil config") + #expect(decoded.accountId == Self.validAccountId) + let requests = await sut.httpClient.requests + let request = try #require(requests.first, "the service issued no request to the transport") + let components = try #require( + URLComponents(url: request.url, resolvingAgainstBaseURL: false), + "built URL is not decomposable into URLComponents" + ) + let queryItems = components.queryItems ?? [] + #expect(queryItems.filter { $0.name == "exp" }.first?.value == "777") + #expect(queryItems.filter { $0.name == "_conv_low_cache" }.count == 1) + } + + // MARK: - fetchExperienceConfig: cache-write elimination (widened IOS-1 skip) + + /// The widened write-skip: `fetchExperienceConfig` NEVER writes the response to the disk + /// cache — even when `debugToken == nil` (unlike the ordinary `fetchLiveConfig` path, whose + /// write-skip today is conditioned on `debugToken != nil`). Table-driven over `debugToken` + /// so the shared fetch + read-back-must-throw assertion lives once (SonarQube new-code- + /// duplication discipline); `expectWrite` is always `false` on THIS path for both cases — + /// the parameterization exists to prove the `debugToken == nil` case is not an accidental + /// pass-through of the ordinary write-through contract. + @Test( + "fetchExperienceConfig never writes to the disk cache, regardless of debugToken", + arguments: [String?.none, "qa-token"] + ) + func fetchExperienceConfigNeverWritesToDiskCache(debugToken: String?) async throws { + let sut = makeSUT(httpResponse: Self.validConfigJSON, debugToken: debugToken) + + let config = await sut.service.fetchExperienceConfig(experienceId: "888") + + #expect(config != nil, "a valid response must still decode even though it is never cached") + await #expect(throws: (any Error).self) { + _ = try await sut.fileStore.read(from: sut.cacheURL) + } + } +} diff --git a/Tests/ConvertSwiftSDKTests/ConfigFetchServiceTests.swift b/Tests/ConvertSwiftSDKTests/ConfigFetchServiceTests.swift index 01c2d0d..a5071d6 100644 --- a/Tests/ConvertSwiftSDKTests/ConfigFetchServiceTests.swift +++ b/Tests/ConvertSwiftSDKTests/ConfigFetchServiceTests.swift @@ -1,66 +1,33 @@ // Tests/ConvertSwiftSDKTests/ConfigFetchServiceTests.swift // -// RED phase (Epic 2, Story 3 — config-fetch coordinator): this suite exercises -// `ConfigFetchService`, the `Sendable` coordinator that builds the config URL, -// fetches the live config (write-through caching the RAW response bytes), and -// loads / repairs the on-disk cache. The type DOES NOT EXIST YET — the GREEN step -// creates it at `Sources/ConvertSwiftSDK/ConfigFetchService.swift`. Until then this file -// fails to compile with "cannot find 'ConfigFetchService' in scope", which is the -// expected RED state for this TDD cycle. (Every collaborator referenced here — -// `MockHTTPClient`, `MockLogger`, `LockedBox` from `MockPorts.swift`, -// `CoordinatedFileStore`, `ConvertConfiguration`, `CacheLevel`, `ProjectConfig`, -// `LogLevel` — already compiles; only the `ConfigFetchService` references are -// unresolved.) +// `ConfigFetchService` (Epic 2, Story 3) coordinator suite: builds the config URL, fetches the +// live config (write-through caching the RAW response bytes), and loads / repairs the on-disk +// cache. Public API driven by these tests: `buildConfigURL() throws -> URL`, +// `loadCachedConfig() async -> ProjectConfig?`, `fetchLiveConfig() async -> ProjectConfig?` — all +// return optionals; none throw to the caller, none touch `ConfigStore`. // -// ── ASSUMED GREEN INIT SEAM (load-bearing — the implementer MUST match this) ─── -// The service takes the cache URL via init so tests inject a UNIQUE TEMP URL and -// never touch the real Application Support directory (where -// `CoordinatedFileStore.configCacheURL(for:)` points). In production the `cacheURL` -// argument DEFAULTS to that real path; tests override it. Assumed signature: -// -// init( -// httpClient: any HTTPClient, -// fileStore: CoordinatedFileStore, -// configuration: ConvertConfiguration, -// logger: any Logger, -// cacheURL: URL = CoordinatedFileStore.configCacheURL(for: configuration.sdkKey) -// ) -// -// (A Swift default-argument expression cannot reference an earlier parameter, so the -// GREEN implementer either provides this via a convenience overload / factory or a -// nil-sentinel that resolves to `configCacheURL(for: configuration.sdkKey)` inside -// the body. What the TESTS require is only that `cacheURL:` is injectable; the -// production default path is the implementer's to wire. The seam is the contract.) -// -// Public API driven by these tests: -// * `func buildConfigURL() throws -> URL` -// * `func loadCachedConfig() async -> ProjectConfig?` -// * `func fetchLiveConfig() async -> ProjectConfig?` -// The service returns optionals and does NOT touch ConfigStore. +// ── Test seam ──────────────────────────────────────────────────────────────── +// The designated init takes an explicit `cacheURL` so tests inject a UNIQUE TEMP path and never +// touch the real Application Support directory (`CoordinatedFileStore.configCacheURL(for:)`); a +// convenience init derives the production default from `configuration.sdkKey`. // // ── Transport double: MockHTTPClient (NOT URLProtocolStub) ──────────────────── -// These tests use the `MockHTTPClient` ACTOR from `MockPorts.swift`, never -// `URLProtocolStub`. Rationale: (1) `MockHTTPClient(response: (data, httpResponse))` -// lets a test set the EXACT response `Data` byte-for-byte — required for the raw-byte -// write-through assertion (`fetchLiveConfigWritesRawBytesToCache`), which proves the -// service caches the verbatim bytes from `get()` rather than re-encoding the decoded -// config (a re-encode would reorder keys and the byte-equality would fail). (2) -// `MockHTTPClient` holds NO process-global state — each test gets its own instance — -// so this suite is parallel-safe and needs NO nesting under the `.serialized` -// `URLProtocolStubBackedTests` parent: URLProtocolStub's global `reset()` race -// (documented in `URLSessionHTTPClientTests.swift`) is structurally impossible here. -// `MockHTTPClient.requests` records each request's headers for the auth assertions. +// `MockHTTPClient` (`MockPorts.swift`) lets a test set the EXACT response `Data` byte-for-byte — +// required for the raw-byte write-through assertion (`fetchLiveConfigWritesRawBytesToCache`), +// which proves the service caches the verbatim bytes from `get()` rather than re-encoding the +// decoded config. It holds NO process-global state, so this suite is parallel-safe and needs NO +// nesting under the `.serialized` `URLProtocolStubBackedTests` parent. // -// ── Isolation + cleanup shape (NFR21 — no test artifacts leak) ──────────────── -// Every cache URL is a UNIQUE path under `FileManager.default.temporaryDirectory` -// (a fresh UUID subdirectory) so cases never collide and never touch the real -// Application Support dir. Each UUID dir is recorded and removed in `deinit` -// (swift-testing makes a fresh suite instance per `@Test` and runs `deinit` after -// it). A `final class` (not `struct`) carries the `deinit`; the recorded-dirs set is -// held in a `LockedBox` (the lock-cell from `MockPorts.swift`) so the mutable -// instance state is `Sendable`-safe on this package's macOS 12 / iOS 15 floor (where -// `Synchronization.Mutex` is unavailable) and reads soundly from `deinit` — mirroring +// ── Isolation + cleanup (NFR21 — no test artifacts leak) ─────────────────────── +// Every cache URL is a UNIQUE path under a fresh UUID subdirectory of +// `FileManager.default.temporaryDirectory`, recorded and removed in `deinit` (a `final class`, +// not `struct`, so it can carry one) via a `LockedBox`-guarded set — mirroring // `CoordinatedFileStoreTests`. +// +// ── Companion file ───────────────────────────────────────────────────────────── +// The qs-02 IOS-1 `debugToken` tests live in the sibling `ConfigFetchServiceDebugTokenTests.swift` +// (an extension of this suite), split out to keep this file and its `type_body_length` under the +// project's lint limits. import Testing import Foundation @@ -132,8 +99,9 @@ final class ConfigFetchServiceTests { /// service under test, the transport double (to read recorded request headers), /// the file store + the temp `cacheURL` (to inspect on-disk bytes), and the logger /// (to assert emitted WARNs). A named struct (not a tuple) keeps the `large_tuple` - /// lint rule satisfied and lets tests read handles by name. - private struct SUT { + /// lint rule satisfied and lets tests read handles by name. Not `private` — the + /// companion `ConfigFetchServiceDebugTokenTests.swift` extension names this type. + struct SUT { let service: ConfigFetchService let httpClient: MockHTTPClient let fileStore: CoordinatedFileStore @@ -158,21 +126,33 @@ final class ConfigFetchServiceTests { /// Pass the EXACT bytes when a test asserts on the cached payload. /// - httpError: canned `URLError` thrown by `get()`; takes precedence over a /// configured response (matches `MockHTTPClient` semantics). - private func makeSUT( + /// - debugToken: optional QA debug token (qs-02 IOS-1) — drives the + /// `debug_token=` query param, the forced/deduped `_conv_low_cache=1`, and the + /// cache-elimination behavior of `loadCachedConfig()` / `fetchLiveConfig()`. + /// + /// ── ASSUMED GREEN INIT SEAM (qs-02 IOS-1) ────────────────────────────────────── + /// `ConvertConfiguration.init` does not have `debugToken` yet (current last param is + /// `networkCacheLevel`); this forwards `debugToken` AFTER it, assuming GREEN appends + /// `debugToken: String? = nil` as the new last parameter — a one-line reorder here if not. + /// Not `private` — reused (not duplicated) by the companion + /// `ConfigFetchServiceDebugTokenTests.swift` extension. + func makeSUT( sdkKey: String = ConfigFetchServiceTests.sdkKey, secret: String? = nil, environment: String? = nil, cacheLevel: CacheLevel = .normal, cacheURL: URL? = nil, httpResponse: Data? = nil, - httpError: URLError? = nil + httpError: URLError? = nil, + debugToken: String? = nil ) -> SUT { let resolvedCacheURL = cacheURL ?? uniqueCacheURL() let configuration = ConvertConfiguration( sdkKey: sdkKey, sdkKeySecret: secret, environment: environment, - networkCacheLevel: cacheLevel + networkCacheLevel: cacheLevel, + debugToken: debugToken ) let cannedResponse = httpResponse.map { ($0, okResponse(for: resolvedCacheURL)) } let httpClient = MockHTTPClient(response: cannedResponse, error: httpError) diff --git a/Tests/ConvertSwiftSDKTests/ConvertContextSetPreviewClearsOnFailureTests.swift b/Tests/ConvertSwiftSDKTests/ConvertContextSetPreviewClearsOnFailureTests.swift new file mode 100644 index 0000000..51ab604 --- /dev/null +++ b/Tests/ConvertSwiftSDKTests/ConvertContextSetPreviewClearsOnFailureTests.swift @@ -0,0 +1,72 @@ +// Tests/ConvertSwiftSDKTests/ConvertContextSetPreviewClearsOnFailureTests.swift +// +// JS parity fix (fullstack-v12): the JS reference nulls its `_preview` field on EVERY +// `setPreview` failure path — `ConvertContext.setPreview(experienceId:variationId:)`'s +// inert-on-bad-input guard (`Sources/ConvertSwiftSDK/ConvertContext.swift`) previously left a +// PRIOR successful preview target untouched on a subsequent FAILED resolution, so a stale forced +// decision from an earlier `setPreview` call could survive indefinitely. GREEN now clears +// `PreviewState.forcedVariation` (via `PreviewState.clearForcedVariation()`) on that same guard. +// +// Lives in its OWN file (mirroring `PreviewFeatureZeroTraceTests.swift`'s documented precedent of +// each `ConvertContext*`/`Preview*` suite owning its own self-contained SUT construction rather +// than widening a sibling file's `private` access) — keeps `ConvertContextSetPreviewTests.swift` +// under SwiftLint's `file_length` gate rather than growing it past 400 lines. +import Testing +import Foundation +@testable import ConvertSwiftSDK + +@Suite("ConvertContext setPreview clears a prior target on a failed re-preview (JS parity)") +@MainActor +struct SetPreviewClearsOnFailureTests { + private static let accountId = "acc-preview-clear" + private static let projectId = "proj-preview-clear" + + /// One experience carrying a 100%-traffic control and a 0%-traffic variant — mirrors + /// `ConvertContextSetPreviewTests.previewExpFragment`'s shape (own copy per the file-header + /// precedent above). + private static func makeConfig(experienceId: String, key: String) throws -> ProjectConfig { + let variations = #"[{"id":"normal","key":"control","traffic_allocation":100},"# + + #"{"id":"forced","key":"variant","traffic_allocation":0}]"# + let experience = #"{"id":"\#(experienceId)","key":"\#(key)","type":"a/b","# + + #""audiences":[],"locations":[],"variations":\#(variations)}"# + let ids = #""account_id":"\#(accountId)","project":{"id":"\#(projectId)"}"# + let envelope = #"{\#(ids),"experiences":[\#(experience)]}"# + return try JSONDecoder().decode(ProjectConfig.self, from: Data(envelope.utf8)) + } + + /// A READY SDK over an off-network `MockConfigProvider` — the failing re-preview's + /// `experienceId` is absent from this config, so its `?exp=` fallback fetch is exercised too; + /// the default (no injected `previewHTTPClient`) `MockHTTPClient`-backed seam is not used here + /// since `ConvertSwiftSDK`'s test-seam init requires an explicit stub to avoid a real network + /// call, mirroring `ConvertContextSetPreviewTests.makeSUT`'s `previewHTTPClient` injection. + private func makeReadySDK(experienceId: String, key: String) async throws -> ConvertSwiftSDK { + let config = try Self.makeConfig(experienceId: experienceId, key: key) + let sdk = ConvertSwiftSDK( + configuration: ConvertConfiguration(sdkKey: "preview-clear-key"), + configProvider: MockConfigProvider.ungated(cached: nil, live: config), + logger: NoopLogger(), + previewHTTPClient: MockHTTPClient() + ) + try await sdk.ready() + return sdk + } + + /// JS parity: a FAILED re-preview (an unresolvable `experienceId`) must clear a PRIOR + /// successful preview target rather than leave it stuck — the same experience must decide its + /// NORMAL (100%-allocation) variation afterward, not stay forced to the earlier target. + @Test("a failed re-preview clears a prior successful preview target") + func failedRePreviewClearsPriorForcedVariation() async throws { + let experienceId = "9501" + let key = "clears-key" + let sdk = try await makeReadySDK(experienceId: experienceId, key: key) + let context = sdk.createContext(visitorId: "user-1") + + await context.setPreview(experienceId: experienceId, variationId: "forced") + let forced = await context.runExperience(key) + #expect(forced?.id == "forced", "the first, successful setPreview must still force") + + await context.setPreview(experienceId: "does-not-exist", variationId: "does-not-matter") + let afterFailedRePreview = await context.runExperience(key) + #expect(afterFailedRePreview?.id == "normal", "a failed re-preview must clear the prior forced target") + } +} diff --git a/Tests/ConvertSwiftSDKTests/ConvertContextSetPreviewTests.swift b/Tests/ConvertSwiftSDKTests/ConvertContextSetPreviewTests.swift new file mode 100644 index 0000000..adad8f7 --- /dev/null +++ b/Tests/ConvertSwiftSDKTests/ConvertContextSetPreviewTests.swift @@ -0,0 +1,398 @@ +// Tests/ConvertSwiftSDKTests/ConvertContextSetPreviewTests.swift +// +// RED phase (qs-02 IOS-5 — `ConvertContext.setPreview` wiring). Spec: +// `_bmad-output/planning-artifacts/2026-06-09-convert-ios-sdk/qs-02-experiment-preview.md`, +// contract §2 "Preview input" / §3 "Precedence"; AC4 (forced decision) / AC7 (isolation). +// +// This suite is an INTEGRATION suite over the PUBLIC `ConvertContext` surface +// (`setPreview` / `runExperience` / `runExperiences`) plus `PreviewParam.parse` — it never +// reaches into `PreviewState`'s internal storage shape, so it is agnostic to WHICH internal +// algorithm GREEN picks (eager resolution at `setPreview` time vs. lazy resolution inside +// `runExperience`) as long as the observable contract holds. +// +// Reused from prior stories (read, not reimplemented — verify signatures before use): +// * `PreviewParam.parse(_:) -> (experienceId, variationId)?` — IOS-2, +// `Sources/ConvertSwiftSDKCore/Preview/PreviewParam.swift`. +// * `PreviewDecision.forcedVariation(for:variationId:) -> Variation?` — IOS-3, +// `Sources/ConvertSwiftSDKCore/Preview/PreviewDecision.swift`. +// * `PreviewState` (an `actor` in the `ConvertSwiftSDK` platform target, NOT +// `ConvertSwiftSDKCore`) — IOS-4, `Sources/ConvertSwiftSDK/PreviewState.swift`; its own +// header already predicts IOS-5 will extend it to also hold the per-context preview +// target, mirroring `TrackingState`'s "one actor, held by `let`" shape. +// +// ── New API surface this suite PINS (does not yet exist — GREEN adds it) ─────────────────── +// * `ConvertContext.setPreview(experienceId: String, variationId: String) async` — `Void` +// return (mirrors the sibling `setDefaultSegments`/`setCustomSegments` async setters — +// NOT a fluent `-> ConvertContext` chain like the Android reference, since Swift's +// async/await makes fire-and-forget unnecessary and every other `ConvertContext` mutator +// here is a bare `await` statement). +// * `ConvertSwiftSDK`'s internal test-seam init gains `previewHTTPClient: (any +// HTTPClient)? = nil` (mirrors the existing `configProvider`/`eventSink`/`secureStore` +// injection precedent): `nil` (production) resolves the same real +// `URLSessionHTTPClient(sdkVersion: SDKVersion.current)` the composition root already +// builds for the MAIN config fetch; a test injects a `MockHTTPClient` to stub the +// PER-CONTEXT `?exp=` preview fetch WITHOUT touching the main config's `configProvider` +// seam (the two are separate `ConfigFetchService`/`ConfigProviding` instances — see +// `PreviewStateTests.swift`'s header, which already documents that `ConvertContext` does +// not own a `ConfigFetchService` today and that `createContext` must build a SECOND one). +// * `ConvertSwiftSDK.createContext` builds that second `ConfigFetchService` (over +// `previewHTTPClient`, a fresh `CoordinatedFileStore()`, `self.configuration`, and +// `self.logger` — the EXACT shape `ConvertSwiftSDK.swift` lines ~319-324 already use for +// the main path) and a fresh `PreviewState` over it, passed into a new `ConvertContext` +// init parameter — one `PreviewState` PER CONTEXT (AC7 isolation). +// +// ── Transport double: MockHTTPClient (NOT URLProtocolStub) — deliberate, documented choice ── +// `PreviewStateTests.swift` (the IOS-4 suite exercising this SAME `fetchExperienceConfig` +// seam) explicitly chose `MockHTTPClient` over `URLProtocolStub` for this exact fetch path +// ("keeps the suite parallel-safe (no process-global `URLProtocolStub` registry, no +// `.serialized` nesting needed)"). This suite follows that closer, more specific precedent +// rather than the task brief's looser "(stubbed URLProtocol)" phrasing — a REAL +// `URLSessionHTTPClient` is not required here because no test in this file asserts on +// request-level details (headers, exact URL) of the preview fetch; only PREVIEW OF DECISION, +// not TRANSPORT PLUMBING, is under test. (`DebugTokenRedactionTests`/AC1-AC3, which DO assert +// transport/redaction details, are why `URLSessionHTTPClient` + `URLProtocolStub` exist as a +// house-style pattern — this suite's scope is narrower.) +// +// ── Ordering judgment call: preview check runs AFTER the config-snapshot guard ────────────── +// The task brief cites `ProjectConfig.fullExperience(forKey:)` (`ProjectConfig.swift:177-178`) +// as part of the resolution path, which requires an already-non-nil `config` snapshot — unlike +// the Android reference (`packages/sdk/.../ConvertContext.kt`, `resolvePreviewOverride`), which +// checks the preview override BEFORE its config-ready gate. This suite therefore does not +// assert (and does not require) that a preview can force a decision on a context whose SDK has +// NEVER loaded any config at all; every scenario here awaits `sdk.ready()` first. See the +// dispatching agent's final report for the full grounding citations on the join-key mechanism. +// +// Deliberately OUT OF SCOPE (qs-02 IOS-6, not this task): no assertion that preview enqueues +// no tracking events, no assertion that preview suppresses visitor-state writes, and no +// assertion that preview events ARE tracked either — AC6 zero-trace hardening is untouched. +import Testing +import Foundation +@testable import ConvertSwiftSDK + +@Suite("ConvertContext setPreview wiring (qs-02 IOS-5)") +@MainActor +struct ConvertContextSetPreviewTests { + /// `account_id` / `project.id` shared by every MAIN config this suite builds — declared + /// once so the envelope JSON and the sticky-decision storeKey computation + /// (`"--"`, the shape `ConvertContext.storeKey(for:)` + /// computes) never re-spell them (SonarQube 3% new-duplicated-lines gate). + private static let mainAccountId = "acc-preview" + private static let mainProjectId = "proj-preview" + + // MARK: - Fixture builders (SonarQube new-code-duplication discipline) + + /// One variation row for a ``previewExpFragment`` case table — replaces a 3-member tuple + /// (SwiftLint `large_tuple`) with a named type; same three fields, same call-site shape. + private struct PreviewVariationFixture { let id: String; let key: String; let traffic: Int } + + /// One experience-wire JSON fragment: `type:"a/b"`, no audiences/locations, with the given + /// `status` (default `"active"`) and `variations` (each a ``PreviewVariationFixture`` row). + /// Shared by every fixture this suite builds — CPD is token-based, so ONE fragment builder + /// (not renamed locals per test) keeps the diff under the 3% gate. + private static func previewExpFragment( + id: String, + key: String, + status: String = "active", + variations: [PreviewVariationFixture] + ) -> String { + let variationsJSON = variations.map { + #"{"id":"\#($0.id)","key":"\#($0.key)","traffic_allocation":\#($0.traffic)}"# + }.joined(separator: ",") + let head = #"{"id":"\#(id)","key":"\#(key)","status":"\#(status)","type":"a/b","# + return head + #""audiences":[],"locations":[],"variations":[\#(variationsJSON)]}"# + } + + /// The MAIN config every SUT is built with — `experiences` fragments joined into ONE + /// envelope under the shared ``mainAccountId``/``mainProjectId``, so a seeded sticky + /// decision's storeKey is predictable. `throws` only on malformed JSON + /// (`ProjectConfig.init(from:)` degrades per-field, so a well-formed fragment never throws). + private static func makeMainConfig(experiences: [String] = []) throws -> ProjectConfig { + let envelope = #"{"account_id":"\#(mainAccountId)","project":{"id":"\#(mainProjectId)"},"# + + #""experiences":[\#(experiences.joined(separator: ","))]}"# + return try JSONDecoder().decode(ProjectConfig.self, from: Data(envelope.utf8)) + } + + /// The RAW wire bytes a stubbed `?exp=` preview fetch returns — its OWN, independent + /// `account_id`/`project.id` (irrelevant to `PreviewDecision`, which never reads them). + /// Returned as `Data` (not pre-decoded): `MockHTTPClient` hands its canned response + /// straight to the transport layer; the SDK's OWN `ConfigFetchService` decodes it. + private static func previewFetchBody(experiences: [String]) -> Data { + let envelope = #"{"account_id":"acc-fetched","project":{"id":"proj-fetched"},"# + + #""experiences":[\#(experiences.joined(separator: ","))]}"# + return Data(envelope.utf8) + } + + /// A 200 `HTTPURLResponse` for `MockHTTPClient`'s canned response. The `url` is incidental + /// (`ConfigFetchService` never inspects the response object, only the paired `Data`), so a + /// stable throwaway URL is reused (mirrors `PreviewStateTests.stubResponse`). + private static func okResponse() -> HTTPURLResponse { + guard let response = HTTPURLResponse( + url: FileManager.default.temporaryDirectory, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"] + ) else { + preconditionFailure("HTTPURLResponse(statusCode:200) is non-failing for a valid URL") + } + return response + } + + // MARK: - SUT factory + + /// Everything a test needs: the ready SDK, its injected log spy, and its canonical + /// `DecisionStore` (for seeding a sticky decision directly — the Precedence test). + private struct SUT { + let sdk: ConvertSwiftSDK + let logger: MockLogger + let decisionStore: DecisionStore + } + + /// Builds a READY, off-network SDK: `mainConfig` is served by a `MockConfigProvider` (the + /// MAIN config load never touches the network), and the per-context `?exp=` preview fetch + /// is served by a `MockHTTPClient` seeded with `previewFetchResponse`. `nil` (the default) + /// means every preview fetch hits `MockHTTPClient`'s documented default — + /// `URLError(.badServerResponse)` — which `ConfigFetchService.fetchExperienceConfig` + /// degrades to `nil`; from `setPreview`'s perspective that is indistinguishable from a + /// fetch that succeeded but found nothing, so it doubles for "the experience is + /// unreachable" in the inert-on-bad-input tests. A FRESH in-memory `DecisionStore` over a + /// `MockFileStore` isolates each SUT's sticky state (mirrors the injection precedent + /// across every `ConvertContext*` suite — see `ConvertContextRunExperienceTests.makeReadySDK`). + private func makeSUT( + mainConfig: ProjectConfig, + previewFetchResponse: Data? = nil + ) async throws -> SUT { + let logger = MockLogger() + let decisionStore = DecisionStore(logger: MockLogger(), fileStore: MockFileStore()) + let httpClient = MockHTTPClient(response: previewFetchResponse.map { ($0, Self.okResponse()) }) + let sdk = ConvertSwiftSDK( + configuration: ConvertConfiguration(sdkKey: "test-key"), + configProvider: MockConfigProvider.ungated(cached: nil, live: mainConfig), + logger: logger, + decisionStore: decisionStore, + previewHTTPClient: httpClient + ) + try await sdk.ready() + return SUT(sdk: sdk, logger: logger, decisionStore: decisionStore) + } + + // MARK: - AC4: forced decision via the ?exp= fetch + + /// A DRAFT experience absent from the MAIN config, delivered ONLY via the stubbed `?exp=` + /// preview fetch: after `setPreview`, running it BY KEY on the preview context returns the + /// requested variation — bypassing its `draft` status and its zero-traffic-irrelevant + /// variation weights entirely (`PreviewDecision.forcedVariation` never reads either). + @Test("setPreview forces the variation for a draft experience delivered only via the ?exp= fetch (AC4)") + func setPreviewForcesDraftExperienceViaExpFetch() async throws { + let sut = try await makeSUT( + mainConfig: try Self.makeMainConfig(), + previewFetchResponse: Self.previewFetchBody(experiences: [ + Self.previewExpFragment( + id: "9001", + key: "preview-key", + status: "draft", + variations: [ + PreviewVariationFixture(id: "5001", key: "control", traffic: 50), + PreviewVariationFixture(id: "5002", key: "variant", traffic: 50) + ] + ) + ]) + ) + let context = sut.sdk.createContext(visitorId: "user-1") + + await context.setPreview(experienceId: "9001", variationId: "5002") + let variation = await context.runExperience("preview-key") + + #expect(variation?.id == "5002") + #expect(variation?.key == "variant") + #expect(variation?.experienceKey == "preview-key") + } + + // MARK: - Precedence (contract §3) + + /// `setPreview` beats a PRE-EXISTING sticky decision for the same experience on this + /// context: the visitor already stickily bucketed into `"control"` (seeded directly via + /// `DecisionStore.saveDecision`), yet after `setPreview` targets `"variant"`, + /// `runExperience` returns `"variant"` — normal bucketing/stickiness never even considered. + @Test("setPreview beats a persisted sticky decision for the same experience on this context") + func setPreviewBeatsStickyDecision() async throws { + let experienceId = "9002" + let experienceKey = "precedence-key" + let sut = try await makeSUT(mainConfig: try Self.makeMainConfig(experiences: [ + Self.previewExpFragment( + id: experienceId, + key: experienceKey, + variations: [ + PreviewVariationFixture(id: "6001", key: "control", traffic: 100), + PreviewVariationFixture(id: "6002", key: "variant", traffic: 0) + ] + ) + ])) + let context = sut.sdk.createContext(visitorId: "user-1") + let storeKey = "\(Self.mainAccountId)-\(Self.mainProjectId)-user-1" + await sut.decisionStore.saveDecision(variationId: "6001", experienceId: experienceId, storeKey: storeKey) + + await context.setPreview(experienceId: experienceId, variationId: "6002") + let variation = await context.runExperience(experienceKey) + + #expect(variation?.id == "6002", "preview must beat the pre-existing sticky decision") + } + + // MARK: - Inert on bad input + + /// An `experienceId` that resolves to NOTHING — absent from the local config AND absent + /// after the `?exp=` fetch — is inert: a warning is logged, and this context then behaves + /// FULLY NORMALLY for every experience (a sibling, unrelated experience still buckets its + /// deterministic 100%-traffic variation). + @Test("setPreview with an unknown experienceId logs a warning and falls through to normal decisions") + func setPreviewUnknownExperienceIdIsInert() async throws { + let normalKey = "normal-key" + let normalVariationId = "7001" + let sut = try await makeSUT( + mainConfig: try Self.makeMainConfig(experiences: [ + Self.previewExpFragment( + id: "8001", + key: normalKey, + variations: [PreviewVariationFixture(id: normalVariationId, key: "control", traffic: 100)] + ) + ]), + previewFetchResponse: Self.previewFetchBody(experiences: []) + ) + let context = sut.sdk.createContext(visitorId: "user-1") + + await context.setPreview(experienceId: "does-not-exist", variationId: "does-not-matter") + + #expect(sut.logger.entries().contains { $0.level == .warn }, "an unresolved preview target must log a warning") + + let variation = await context.runExperience(normalKey) + #expect( + variation?.id == normalVariationId, + "an inert preview must not disturb an unrelated experience's decision" + ) + } + + /// A KNOWN `experienceId` but an unknown `variationId` (not present in that experience's + /// `variations`, even after the resolve) is inert THE SAME WAY: a warning is logged, and + /// running that experience by key returns its NORMAL decision, not a forced one. + @Test("setPreview with a known experienceId but unknown variationId logs a warning and decides normally") + func setPreviewUnknownVariationIdIsInert() async throws { + let experienceId = "9003" + let experienceKey = "known-key" + let knownVariationId = "6101" + let sut = try await makeSUT(mainConfig: try Self.makeMainConfig(experiences: [ + Self.previewExpFragment( + id: experienceId, + key: experienceKey, + variations: [PreviewVariationFixture(id: knownVariationId, key: "control", traffic: 100)] + ) + ])) + let context = sut.sdk.createContext(visitorId: "user-1") + + await context.setPreview(experienceId: experienceId, variationId: "does-not-exist-in-experience") + + #expect(sut.logger.entries().contains { $0.level == .warn }, "an unknown variationId must log a warning") + + let variation = await context.runExperience(experienceKey) + #expect(variation?.id == knownVariationId, "an unknown variationId must fall through to the normal decision") + } + + // MARK: - AC7: isolation + + /// A concurrent NON-preview context on the SAME SDK buckets, persists, and decides + /// COMPLETELY normally: preview state lives ONLY on the per-context `PreviewState`, never + /// on any shared `ConvertSwiftSDK`-level state, so a sibling context that never called + /// `setPreview` is entirely unaffected by another context's active preview target. + @Test("a concurrent non-preview context on the same SDK buckets normally (AC7 isolation)") + func concurrentNonPreviewContextIsolated() async throws { + let experienceId = "9004" + let experienceKey = "iso-key" + let normalVariationId = "6201" + let forcedVariationId = "6202" + let sut = try await makeSUT(mainConfig: try Self.makeMainConfig(experiences: [ + Self.previewExpFragment( + id: experienceId, + key: experienceKey, + variations: [ + PreviewVariationFixture(id: normalVariationId, key: "control", traffic: 100), + PreviewVariationFixture(id: forcedVariationId, key: "variant", traffic: 0) + ] + ) + ])) + + let previewContext = sut.sdk.createContext(visitorId: "preview-visitor") + await previewContext.setPreview(experienceId: experienceId, variationId: forcedVariationId) + let forced = await previewContext.runExperience(experienceKey) + #expect(forced?.id == forcedVariationId, "the preview context itself must still force") + + let otherContext = sut.sdk.createContext(visitorId: "other-visitor") + let normal = await otherContext.runExperience(experienceKey) + #expect(normal?.id == normalVariationId, "a context that never called setPreview must bucket normally") + } + + // MARK: - runExperiences: only the target is forced + + /// `runExperiences()` forces ONLY the previewed experience; a sibling, non-previewed + /// experience in the SAME bulk call still decides normally (contract §2: "other + /// experiences still evaluate and decide normally for coherent rendering"). + @Test("runExperiences forces only the previewed experience; siblings still decide normally") + func setPreviewForcesOnlyTargetInRunExperiences() async throws { + let targetId = "9005" + let targetKey = "key-b" + let siblingKey = "key-a" + let siblingVariationId = "6301" + let normalTargetVariationId = "6401" + let forcedTargetVariationId = "6402" + let sut = try await makeSUT(mainConfig: try Self.makeMainConfig(experiences: [ + Self.previewExpFragment( + id: "9099", + key: siblingKey, + variations: [PreviewVariationFixture(id: siblingVariationId, key: "control", traffic: 100)] + ), + Self.previewExpFragment( + id: targetId, + key: targetKey, + variations: [ + PreviewVariationFixture(id: normalTargetVariationId, key: "control", traffic: 100), + PreviewVariationFixture(id: forcedTargetVariationId, key: "variant", traffic: 0) + ] + ) + ])) + let context = sut.sdk.createContext(visitorId: "user-1") + + await context.setPreview(experienceId: targetId, variationId: forcedTargetVariationId) + let results = await context.runExperiences() + + let sibling = results.first { $0.experienceKey == siblingKey } + let target = results.first { $0.experienceKey == targetKey } + #expect(sibling?.id == siblingVariationId, "a non-previewed sibling experience must still decide normally") + #expect(target?.id == forcedTargetVariationId, "the previewed experience must be forced") + } + + // MARK: - Wiring from PreviewParam + + /// The intended host entrypoint end-to-end: `PreviewParam.parse` extracts the + /// `(experienceId, variationId)` pair from the canonical `"{expId}.{varId}"` link value, + /// and feeding that pair straight into `setPreview` forces the requested variation. + @Test("PreviewParam.parse feeds setPreview end-to-end") + func parsePreviewParamThenSetPreviewForces() async throws { + let sut = try await makeSUT( + mainConfig: try Self.makeMainConfig(), + previewFetchResponse: Self.previewFetchBody(experiences: [ + Self.previewExpFragment( + id: "9006", + key: "link-key", + variations: [ + PreviewVariationFixture(id: "6501", key: "control", traffic: 100), + PreviewVariationFixture(id: "6502", key: "variant", traffic: 0) + ] + ) + ]) + ) + let context = sut.sdk.createContext(visitorId: "user-1") + + let parsed = try #require(PreviewParam.parse("9006.6502")) + await context.setPreview(experienceId: parsed.experienceId, variationId: parsed.variationId) + let variation = await context.runExperience("link-key") + + #expect(variation?.id == "6502") + } +} diff --git a/Tests/ConvertSwiftSDKTests/DebugTokenRedactionTests.swift b/Tests/ConvertSwiftSDKTests/DebugTokenRedactionTests.swift new file mode 100644 index 0000000..c79533f --- /dev/null +++ b/Tests/ConvertSwiftSDKTests/DebugTokenRedactionTests.swift @@ -0,0 +1,104 @@ +// Tests/ConvertSwiftSDKTests/DebugTokenRedactionTests.swift +// +// RED phase (qs-02 IOS-1, AC3 — token hygiene): proves the CRITICAL VERIFIED DIVERGENCE that +// `ConfigFetchService`'s network-failure WARN line (`fetchLiveConfig`'s catch around the +// `httpClient.get(...)` call, `Sources/ConvertSwiftSDK/ConfigFetchService.swift` lines ~210-217) +// currently LEAKS a `debugToken` value in clear. `String(describing: error)` on a genuine +// `URLSession` transport failure embeds the failing URL — Foundation's URL Loading System +// attaches `NSErrorFailingURLStringKey` / `NSURLErrorFailingURLStringErrorKey` to any surfaced +// `URLError` automatically, regardless of what failed the load — so when the URL carries +// `debug_token=`, that value rides straight into the WARN line via `toLoggable(_:)` +// (`Sources/ConvertSwiftSDKCore/Logging/ToLoggable.swift`), whose `stripSecretQueryParams(from:)` +// regex today only matches `sdkKeySecret` / `sdkKey` — NOT `debug_token`. +// +// This file fails to compile until `ConvertConfiguration.debugToken` exists (GREEN, qs-02 IOS-1) +// — the same RED signal as `ConfigFetchServiceTests.swift`. Once it compiles, the assertion +// itself is EXPECTED TO GENUINELY FAIL today (not merely fail to compile): the WARN line will +// contain the raw token, because the redaction gap in `ToLoggable.swift` has not yet been closed. +// GREEN must widen the redaction (or add an equivalent) so this test passes. +// +// A REAL `URLSessionHTTPClient` over a stubbed `URLSession` (NOT `MockHTTPClient`) is required — +// only a genuine `URLSession` transport error carries the failing-URL `userInfo` that reproduces +// the actual leak; a hand-thrown `URLError` from `MockHTTPClient` would not exercise that +// Foundation behavior and would give a false pass. +import Testing +import Foundation +@testable import ConvertSwiftSDK + +// Nested under the shared `.serialized` `URLProtocolStubBackedTests` parent (declared in +// `Adapters/URLSessionHTTPClientTests.swift`) — this suite drives the PROCESS-GLOBAL +// `URLProtocolStub`, whose registries are wiped wholesale by any `reset()`. A separate top-level +// `.serialized` suite would still run in PARALLEL relative to the other stub-driving suites +// (`.serialized` orders only WITHIN a suite), so nesting here is what closes that cross-suite +// race — mirroring `FullChainIntegrationTests.swift`'s identical nesting rationale. +extension URLProtocolStubBackedTests { + +/// `.serialized` (belt-and-suspenders atop the parent's scope) — drives the process-global +/// `URLProtocolStub` and resets it at construction AND teardown. A `final class` (not `struct`) +/// so `deinit` can run the after-each reset AND remove this suite's temp cache directory, +/// mirroring `ConfigFetchServiceTests`'s NFR21 cleanup discipline. +@Suite("DebugTokenRedaction", .serialized) +final class DebugTokenRedactionTests { + /// The QA debug token this suite proves never leaks in clear. + private static let debugToken = "qa-secret-token-xyz" + /// SDK key for the configuration under test. + private static let sdkKey = "sk_preview_test" + + /// The unique temp cache directory this test's `ConfigFetchService` is wired to — never the + /// real Application Support location. Removed in `deinit`. + private let cacheDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + + /// Resets the process-global `URLProtocolStub` before each test (fresh suite instance per + /// `@Test`), so no registry entry leaks in from a prior case. + init() { + URLProtocolStub.reset() + } + + /// Resets `URLProtocolStub` again and removes this suite's temp cache directory, so nothing + /// leaks into an unrelated suite or a later run (NFR21). + deinit { + URLProtocolStub.reset() + try? FileManager.default.removeItem(at: cacheDir) + } + + /// AC3 — token hygiene (redaction). Builds a REAL stub-backed `URLSessionHTTPClient`, a + /// `ConvertConfiguration` carrying a `debugToken`, and a `ConfigFetchService` over a unique + /// temp `cacheURL`. Resolves the URL to stub via the service's own `buildConfigURL()` (never + /// hand-constructed, so the test never depends on query-item ordering), registers a transport + /// FAILURE against it, calls `fetchLiveConfig()`, and asserts: (1) the fetch degrades to + /// `nil` as documented, (2) at least one WARN was logged, and (3) NONE of the logged messages + /// contain the raw token value or the `debug_token=` substring. + @Test("fetchLiveConfig's failure WARN never leaks the debugToken value or the debug_token= param") + func warnLineDoesNotLeakDebugToken() async throws { + let configuration = ConvertConfiguration(sdkKey: Self.sdkKey, debugToken: Self.debugToken) + let sessionConfiguration = URLSessionConfiguration.ephemeral + URLProtocolStub.install(into: sessionConfiguration) + let session = URLSession(configuration: sessionConfiguration) + let httpClient = URLSessionHTTPClient(session: session, sdkVersion: "test") + let logger = MockLogger() + let cacheURL = cacheDir.appendingPathComponent("config-cache.json") + let service = ConfigFetchService( + httpClient: httpClient, + fileStore: CoordinatedFileStore(), + configuration: configuration, + logger: logger, + cacheURL: cacheURL + ) + + let url = try service.buildConfigURL() + URLProtocolStub.stubFailure(url: url, error: URLError(.cannotConnectToHost)) + + let config = await service.fetchLiveConfig() + + #expect(config == nil) + let entries = logger.entries() + #expect(!entries.isEmpty, "expected the network-failure path to log at least one WARN") + for entry in entries { + #expect(!entry.message.contains(Self.debugToken)) + #expect(!entry.message.contains("debug_token=")) + } + } +} + +} // extension URLProtocolStubBackedTests diff --git a/Tests/ConvertSwiftSDKTests/PreviewFeatureZeroTraceTests.swift b/Tests/ConvertSwiftSDKTests/PreviewFeatureZeroTraceTests.swift new file mode 100644 index 0000000..8ba713e --- /dev/null +++ b/Tests/ConvertSwiftSDKTests/PreviewFeatureZeroTraceTests.swift @@ -0,0 +1,289 @@ +// Tests/ConvertSwiftSDKTests/PreviewFeatureZeroTraceTests.swift +// +// RED phase (qs-02 IOS-fix2 — FEATURE-path zero-trace hardening, decision-audit round-2 finding). +// Spec: `_bmad-output/planning-artifacts/2026-06-09-convert-ios-sdk/qs-02-experiment-preview.md`, +// contract §2 "Zero-trace (hard requirement)"; AC6 (zero trace) with AC7 (isolation) as a companion +// regression guard. Lives in its OWN file (mirroring `ConvertContextRunFeaturesTests`'s documented +// precedent of splitting the feature-wiring concern into its own suite file) — also keeps +// `PreviewZeroTraceTests.swift` under SwiftLint's `file_length` gate rather than growing it past 400 +// lines. +// +// ── The gap this suite documents (does not exist yet — this is why it is RED) ────────────────────── +// `PreviewZeroTraceTests` already covers the EXPERIENCE path (`runExperience`/`runExperiences`), the +// CONVERSION path (`trackConversion`), and the SEGMENT setters (`setDefaultSegments`/ +// `setCustomSegments`) — all four already thread the per-context `previewActive` gate. The FEATURE +// path (`ConvertContext.runFeature`/`runFeatures`, `Sources/ConvertSwiftSDK/ConvertContext.swift:466`, +// `:509`) carries NO reference to `previewState` at all: it delegates straight to +// `FeatureManager.evaluateFeature`/`evaluateAllFeatures` +// (`Sources/ConvertSwiftSDKCore/Experience/FeatureManager.swift:83`,`:149`), which call +// `experienceManager.selectVariation(...)` with `enableTracking: true` HARDCODED +// (`FeatureManager.swift:107-116`) and pass NO `persistDecision` argument — so it takes +// `ExperienceManager.selectVariation`'s own default, `true` +// (`Sources/ConvertSwiftSDKCore/Experience/ExperienceManager.swift:159`). So a preview-active context +// running a feature whose carrying experience buckets a FRESH visitor still fires the sticky-decision +// WRITE (`decisionStore.saveDecision` → `DecisionStore.swift:126-147`'s `fileStore.write` at `:146`) +// AND the `.bucketing` enqueue (`BucketingManager.swift:126-128`) — violating contract §2's "ALL +// visitor-state persistence writes are disabled" / "ALL tracking is disabled ... overrides all three +// tracking layers." +// +// ── Why a REAL `EventQueue` + REAL `CoordinatedFileEventQueueStore` (mirrors the sibling suite) ───── +// Same rationale as `PreviewZeroTraceTests` (see its file header): AC6 requires the on-disk queue +// store be exercised, so this suite wires a REAL `EventQueue` over a REAL +// `CoordinatedFileEventQueueStore` at a UUID-named temp file through a `MockEventUploader` spy. The +// SUT here is an intentional, self-contained copy of that wiring shape (own queue/decision-store/SDK +// construction) rather than widening the sibling file's `private` access — each `ConvertContext*`/ +// `Preview*` suite in this codebase already owns its own SDK/store construction (compare +// `ConvertContextRunFeaturesTests`'s `makeReadySDK` to `PreviewZeroTraceTests`'s `makeSUT`: analogous, +// independently-owned wiring per suite file, not a per-CASE copy-paste WITHIN one file — the shape the +// SonarQube 3% new-duplicated-lines gate this codebase honors actually targets), so this is the +// established cross-suite pattern, not a new duplication source. +import Testing +import Foundation +@testable import ConvertSwiftSDK + +@Suite("Preview zero-trace hardening — FEATURE path (qs-02 IOS-fix2)") +@MainActor +struct PreviewFeatureZeroTraceTests { + // MARK: - Fixed fixture identifiers (single owner each — SonarQube 3% new-duplicated-lines gate) + + private static let accountId = "acc-preview-feat" + private static let projectId = "proj-preview-feat" + private static let targetExperienceId = "9301" + private static let targetKey = "target-key" + private static let targetForcedVariationId = "t-var" + private static let featureKey = "feature-flag" + private static let featureIdInt = 20031 + private static let featureExperienceId = "9302" + private static let featureExperienceKey = "feature-carrier-key" + private static let featureVariationId = "feat-var-pz" + + // MARK: - Config fixture + + /// A `ProjectConfig` carrying an UNRELATED preview TARGET experience (two variations — `"t-ctrl"` + /// at 100% traffic, the forced one at 0%, so a natural bucket would NEVER select it — mirrors + /// `PreviewZeroTraceTests.makeConfig()`'s target shape) PLUS a SEPARATE `features`-carrying + /// experience this suite drives via `runFeatures()`. The feature-carrying shape mirrors + /// `Support/TestFixtures.swift:264`'s `makeFeatureConfig()` (INTEGER change `id`, INTEGER + /// `data.feature_id` bound to the STRING `features[].id` by + /// `String(feature_id) == feature.id` — same load-bearing trap documented there): its SOLE + /// variation is 100%-traffic, so it buckets for ANY visitor hash — always a FRESH, + /// never-before-decided bucket against each test's own isolated `DecisionStore`/`MockFileStore` + /// (no sticky hit is possible). `throws` only on malformed JSON (`ProjectConfig.init(from:)` + /// degrades per-field, so this shape never throws). + private static func makeConfig() throws -> ProjectConfig { + let targetVariations = #"[{"id":"t-ctrl","key":"control","traffic_allocation":100},"# + + #"{"id":"\#(targetForcedVariationId)","key":"variant","traffic_allocation":0}]"# + let target = #"{"id":"\#(targetExperienceId)","key":"\#(targetKey)","type":"a/b","# + + #""audiences":[],"locations":[],"variations":\#(targetVariations)}"# + let variablesData = #"{"flag":true}"# + let variableTypes = #"[{"key":"flag","type":"boolean"}]"# + let changeData = #""data":{"feature_id":\#(featureIdInt),"variables_data":\#(variablesData)}"# + let change = #"{"id":1,"type":"fullStackFeature",\#(changeData)}"# + let featureVariationHead = #"{"id":"\#(featureVariationId)","key":"feat-var-key","# + + #""traffic_allocation":100,"# + let featureVariation = featureVariationHead + #""changes":[\#(change)]}"# + let featureExperienceHead = #"{"id":"\#(featureExperienceId)","key":"\#(featureExperienceKey)","# + + #""type":"a/b","# + let featureExperience = featureExperienceHead + + #""audiences":[],"locations":[],"variations":[\#(featureVariation)]}"# + let featureHead = #"{"id":"\#(featureIdInt)","name":"\#(featureKey)-name","key":"\#(featureKey)","# + let feature = featureHead + #""variables":\#(variableTypes)}"# + let ids = #""account_id":"\#(accountId)","project":{"id":"\#(projectId)"}"# + let envelope = #"{\#(ids),"experiences":[\#(target),\#(featureExperience)],"features":[\#(feature)]}"# + return try JSONDecoder().decode(ProjectConfig.self, from: Data(envelope.utf8)) + } + + /// The REAL on-disk path `DecisionStore.resolveStoreURL()` computes + /// (`DecisionStore.swift:296-307` — a `private static` method, so this replicates its documented + /// Application-Support-first algorithm rather than reaching it). Deterministic within a process, + /// so it is a stable key into each test's OWN fresh `MockFileStore` instance. + private static func decisionStoreFileURL() throws -> URL { + let appSupport = try FileManager.default.url( + for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true + ) + return appSupport.appendingPathComponent("convert-decision-store.json") + } + + // MARK: - SUT + + /// Mirrors `PreviewZeroTraceTests.SUT` — a READY SDK wired to a REAL `EventQueue` over a REAL + /// `CoordinatedFileEventQueueStore` at a UUID-named temp file (shipping through a + /// `MockEventUploader` spy), and the SDK's canonical `DecisionStore` over a `MockFileStore` spy. + private struct SUT: Sendable { + let sdk: ConvertSwiftSDK + let queue: EventQueue + let queueStoreURL: URL + let queueStore: CoordinatedFileEventQueueStore + let uploader: MockEventUploader + let decisionFileStore: MockFileStore + } + + /// Builds an isolated SUT — see `PreviewZeroTraceTests.makeSUT()` for the identical wiring + /// rationale (this suite owns its own copy so it stays independent of that file's `private` + /// members and both files stay under the `file_length` gate). + private func makeSUT() async throws -> SUT { + let queueStoreURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString + ".json") + let queueStore = CoordinatedFileEventQueueStore(fileURL: queueStoreURL, logger: NoopLogger()) + let uploader = MockEventUploader() + let queue = EventQueue( + accountId: Self.accountId, + projectId: Self.projectId, + uploader: uploader, + eventBus: EventBus(), + store: queueStore + ) + let decisionFileStore = MockFileStore() + let decisionStore = DecisionStore(logger: NoopLogger(), fileStore: decisionFileStore) + let sdk = ConvertSwiftSDK( + configuration: ConvertConfiguration(sdkKey: "zero-trace-feature-key"), + configProvider: MockConfigProvider.ungated(cached: nil, live: try Self.makeConfig()), + eventSink: queue, + logger: NoopLogger(), + decisionStore: decisionStore + ) + try await sdk.ready() + return SUT( + sdk: sdk, + queue: queue, + queueStoreURL: queueStoreURL, + queueStore: queueStore, + uploader: uploader, + decisionFileStore: decisionFileStore + ) + } + + // MARK: - `.bucketing` observer capture (qs-02 Fix 1 — mirrors + // `PreviewZeroTraceTests.subscribeBucketingCount`'s `sdk.on(.bucketing)` + `LockedBox` pattern; + // this file owns its own copy per the established per-suite-file wiring precedent above) + + /// Subscribes a `.bucketing` fire-count counter on `sdk`'s bus, returning the counter and the + /// token to `off` when the caller is done. `EventBus.fire` delivers each callback as an + /// independent `MainActor` task, so callers `await MainActor.run { }` before reading the count. + private func subscribeBucketingCount(on sdk: ConvertSwiftSDK) async -> (LockedBox, EventListenerToken) { + let fired = LockedBox(0) + let token = await sdk.on(.bucketing) { _ in fired.withLock { $0 += 1 } } + return (fired, token) + } + + // MARK: - AC6 gap: the FEATURE path is NOT currently preview-gated (RED) + + /// Confirms preview is genuinely active first (a forced `runExperience` result on the UNRELATED + /// preview-target experience — `runFeatures()` never touches that experience), then calls + /// `runFeatures()` for a feature carried by a SEPARATE 100%-traffic, never-before-decided + /// experience and asserts: the feature STILL resolves `.enabled` (coherent rendering, contract + /// §2 — this is NOT an early-return-to-disabled test) while ZERO writes ever reach the shared + /// `MockFileStore` spy, ZERO `.bucketing` entries ever reach the on-disk queue file / the + /// uploader, and (qs-02 Fix 1) ZERO `.bucketing` OBSERVER events are ever delivered. Expected to + /// FAIL (RED) today, per the file-header gap. + @Test("preview-active context's runFeatures produces zero trace and still resolves the feature (AC6 gap, IOS-fix2)") + func previewContextFeaturePathProducesZeroTrace() async throws { + let sut = try await makeSUT() + defer { try? FileManager.default.removeItem(at: sut.queueStoreURL) } + let context = sut.sdk.createContext(visitorId: "preview-feature-visitor") + let (bucketingFired, bucketingToken) = await subscribeBucketingCount(on: sut.sdk) + + await context.setPreview(experienceId: Self.targetExperienceId, variationId: Self.targetForcedVariationId) + let forced = await context.runExperience(Self.targetKey) + #expect(forced?.id == Self.targetForcedVariationId, "preview must be genuinely active for this context") + + let features = await context.runFeatures() + #expect( + features.first(where: { $0.key == Self.featureKey })?.status == .enabled, + "coherent rendering: the feature must still resolve correctly under preview (contract §2)" + ) + + await sut.queue.persistBeforeBackground() + let persisted = try await sut.queueStore.load() + #expect(persisted.isEmpty, "zero-trace: runFeatures must not persist a .bucketing event under preview") + #expect( + !FileManager.default.fileExists(atPath: sut.queueStoreURL.path), + "zero-trace: no queue file was ever written by runFeatures under preview" + ) + + await sut.queue.flush() + #expect(await sut.uploader.callCount == 0, "zero-trace: no batch from runFeatures must ever reach the uploader") + + let decisionURL = try Self.decisionStoreFileURL() + #expect( + await sut.decisionFileStore.contents(at: decisionURL) == nil, + "zero-trace: runFeatures must not write a sticky decision under preview" + ) + + await MainActor.run { } + #expect( + bucketingFired.get == 0, + "zero-trace (Fix 1): runFeatures must not fire the .bucketing observer event under preview" + ) + await sut.sdk.off(bucketingToken) + } + + /// Companion to ``previewContextFeaturePathProducesZeroTrace()`` for the SINGULAR `runFeature(_:)` + /// entry point (qs-02 Fix 1): under preview, `runFeature(_:)` must still resolve the feature + /// (coherent rendering) while firing ZERO `.bucketing` observer events. + @Test("preview-active context's runFeature produces zero .bucketing observer events (Fix 1)") + func previewContextSingularRunFeatureProducesZeroBucketingEvents() async throws { + let sut = try await makeSUT() + defer { try? FileManager.default.removeItem(at: sut.queueStoreURL) } + let context = sut.sdk.createContext(visitorId: "preview-single-feature-visitor") + let (bucketingFired, bucketingToken) = await subscribeBucketingCount(on: sut.sdk) + + await context.setPreview(experienceId: Self.targetExperienceId, variationId: Self.targetForcedVariationId) + let forced = await context.runExperience(Self.targetKey) + #expect(forced?.id == Self.targetForcedVariationId, "preview must be genuinely active for this context") + + let feature = await context.runFeature(Self.featureKey) + #expect( + feature.status == .enabled, + "coherent rendering: the feature must still resolve correctly under preview (contract §2)" + ) + + await MainActor.run { } + #expect( + bucketingFired.get == 0, + "zero-trace (Fix 1): runFeature must not fire the .bucketing observer event under preview" + ) + await sut.sdk.off(bucketingToken) + } + + // MARK: - AC7 companion: a non-preview context's feature path still tracks + persists normally + + /// Companion regression guard (AC7, F-171): the SAME feature-carrying config, on a NON-preview + /// context, must still persist the sticky decision AND enqueue the `.bucketing` event exactly as + /// today — proving the eventual GREEN fix scopes the gate to `previewActive` rather than changing + /// the feature path's tracking behaviour at large (`runFeature`/`runFeatures` stay parameterless + /// per F-171 — no `enableTracking` argument is added). A FRESH, ISOLATED `SUT` (own queue/decision + /// store), matching `PreviewZeroTraceTests.nonPreviewContextStillTracksAndPersists()`'s isolation + /// rationale. Expected to PASS today (pins the CURRENT correct non-preview behaviour before GREEN + /// touches the feature path). Also asserts (Fix 1 regression guard) that the `.bucketing` OBSERVER + /// event still fires normally when preview is NOT active. + @Test("a non-preview context's runFeatures still enqueues and persists normally (AC7 regression guard)") + func nonPreviewContextFeaturePathStillPersists() async throws { + let sut = try await makeSUT() + defer { try? FileManager.default.removeItem(at: sut.queueStoreURL) } + let context = sut.sdk.createContext(visitorId: "normal-feature-visitor") + let (bucketingFired, bucketingToken) = await subscribeBucketingCount(on: sut.sdk) + + let features = await context.runFeatures() + #expect(features.first(where: { $0.key == Self.featureKey })?.status == .enabled) + await MainActor.run { } + #expect(bucketingFired.get == 1, "a non-preview .bucketing observer event must still fire normally") + await sut.sdk.off(bucketingToken) + + await sut.queue.persistBeforeBackground() + let persisted = try await sut.queueStore.load() + #expect(!persisted.isEmpty, "a non-preview context's runFeatures must still reach the on-disk queue") + + await sut.queue.flush() + #expect( + await sut.uploader.callCount > 0, + "a non-preview context's runFeatures batch must still reach the uploader" + ) + + let decisionURL = try Self.decisionStoreFileURL() + #expect( + await sut.decisionFileStore.contents(at: decisionURL) != nil, + "a non-preview context's runFeatures must still write a sticky decision" + ) + } +} diff --git a/Tests/ConvertSwiftSDKTests/PreviewStateTests.swift b/Tests/ConvertSwiftSDKTests/PreviewStateTests.swift new file mode 100644 index 0000000..dbce9e1 --- /dev/null +++ b/Tests/ConvertSwiftSDKTests/PreviewStateTests.swift @@ -0,0 +1,210 @@ +// Tests/ConvertSwiftSDKTests/PreviewStateTests.swift +// +// RED phase (qs-02 IOS-4, AC8 — preview memoization). Pins `PreviewState`: an `actor`, living in +// the `ConvertSwiftSDK` platform target (it holds a concrete `ConfigFetchService`, which composes +// Foundation-only ports — mirrors `TrackingState`'s placement rationale), that memoizes +// `ConfigFetchService.fetchExperienceConfig(experienceId:)` results IN-MEMORY ONLY (never the +// on-disk config cache), keyed per `experienceId`, with a 60s TTL driven by an INJECTABLE +// `Clock` (the SAME `Clock` port `ConfigRefreshScheduler` already uses — this reuses the +// existing `MockClock` test double; no new clock abstraction is introduced). Expired entries are +// swept on EVERY access (both the read path — a memo hit/miss check — and the write path — after +// a fresh fetch is inserted), bounding growth without a separate timer. +// +// `PreviewState` is deliberately NOT single-purpose to the memo: it is the actor a later story +// (qs-02 IOS-5) will ALSO use to hold the per-context current `(experienceId, variationId)` +// preview target (mirroring `TrackingState`'s "one actor, held by `let`, on the owning type" +// shape) — this file exercises only the memo surface these tests need; IOS-5 adds to the SAME +// type rather than introducing a second one. +// +// ── Integration-seam decision this file pins (qs-02 IOS-4) ──────────────────────────────────── +// `PreviewState` is constructed over a CONCRETE `ConfigFetchService` value (not a protocol or a +// closure): `ConfigFetchService` is already a cheap `Sendable` struct (`ConfigFetchService.swift` +// lines ~34-70), so no new seam type is needed for a single-consumer dependency. A later story +// (IOS-5) will have `ConvertContext` hold a `PreviewState` built by `ConvertSwiftSDK.createContext` +// over a second `ConfigFetchService` instance constructed there (the composition-root local built +// inside the config-load `Task`, `ConvertSwiftSDK.swift` lines ~319-324, is not stored anywhere a +// context could reach — `ConvertContext` does not own a `ConfigFetchService` today). +// +// This file fails to compile until `PreviewState` and +// `ConfigFetchService.fetchExperienceConfig(experienceId:)` exist (GREEN, qs-02 IOS-4). +// +// ── Transport double: MockHTTPClient (NOT URLProtocolStub) ──────────────────────────────────── +// Mirrors `ConfigFetchServiceTests.swift`'s house style: ONE canned response is reused across +// every `experienceId` in a case — these tests assert FETCH COUNTS (via `MockHTTPClient.requests`, +// filtered by the request URL's `exp=` query value), never per-id response-body differentiation, +// so a single canned response is sufficient and keeps the suite parallel-safe (no process-global +// `URLProtocolStub` registry, no `.serialized` nesting needed). +import Testing +import Foundation +@testable import ConvertSwiftSDK + +@Suite("PreviewState") +final class PreviewStateTests { + /// Temp directories created by ``uniqueCacheURL()``, removed in ``deinit`` so no test + /// artifact survives the run (NFR21) — mirrors `ConfigFetchServiceTests`'s cleanup discipline. + private let createdDirs = LockedBox<[URL]>([]) + + deinit { + let manager = FileManager.default + for dir in createdDirs.get { + try? manager.removeItem(at: dir) + } + } + + // MARK: - Factories / helpers (SonarQube new-code duplication discipline) + + /// Builds a UNIQUE cache-file URL under a fresh UUID temp subdirectory and records that + /// subdirectory for ``deinit`` cleanup — the exp path must never touch this file, so every + /// test that asserts "disk cache untouched" reads back from exactly this path. + private func uniqueCacheURL() -> URL { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + createdDirs.withLock { $0.append(dir) } + return dir.appendingPathComponent("config-cache.json") + } + + /// Builds a 200 `HTTPURLResponse`. The `url` is incidental (`ConfigFetchService` never + /// inspects the response object, only the paired `Data`), so the temp `cacheURL` itself is + /// reused rather than constructing a second URL literal. + private func stubResponse(for url: URL) -> HTTPURLResponse { + guard let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"] + ) else { + preconditionFailure("HTTPURLResponse(statusCode:200) is non-failing for a valid URL") + } + return response + } + + /// Everything a test needs to drive and inspect a ``PreviewState``: the actor under test, + /// the transport double (to count/filter requests by `exp=` value), the file store + temp + /// `cacheURL` (to prove the on-disk config cache is never touched), and the injectable + /// ``MockClock`` (to advance time synthetically per NFR21 — no wall-clock waits). + private struct SUT { + let previewState: PreviewState + let httpClient: MockHTTPClient + let fileStore: CoordinatedFileStore + let cacheURL: URL + let clock: MockClock + } + + /// Single wiring point for every test in this suite: a `ConvertConfiguration`, a + /// `MockHTTPClient` canning ONE valid config response (reused across every `experienceId` + /// a test drives), a real `CoordinatedFileStore` at a unique temp `cacheURL`, and a + /// `MockClock` seeded at `now` (defaulting to the Unix epoch so `addingTimeInterval` math in + /// the tests reads as plain elapsed seconds). + private func makeSUT(now: Date = Date(timeIntervalSince1970: 0)) -> SUT { + let cacheURL = uniqueCacheURL() + let configuration = ConvertConfiguration(sdkKey: "sk_preview_state") + let response = (ConfigFetchServiceTests.validConfigJSON, stubResponse(for: cacheURL)) + let httpClient = MockHTTPClient(response: response) + let fileStore = CoordinatedFileStore() + let fetchService = ConfigFetchService( + httpClient: httpClient, + fileStore: fileStore, + configuration: configuration, + logger: MockLogger(), + cacheURL: cacheURL + ) + let clock = MockClock(now: now) + let previewState = PreviewState(fetchService: fetchService, clock: clock) + return SUT( + previewState: previewState, + httpClient: httpClient, + fileStore: fileStore, + cacheURL: cacheURL, + clock: clock + ) + } + + /// Counts `requests` whose URL carries `exp={experienceId}` — shared by every test that + /// asserts a fetch count, so the `URLComponents` filter logic lives once (SonarQube + /// new-code-duplication discipline). + private func fetchCount(for experienceId: String, in requests: [MockHTTPClient.Request]) -> Int { + requests.filter { request in + URLComponents(url: request.url, resolvingAgainstBaseURL: false)? + .queryItems?.contains { $0.name == "exp" && $0.value == experienceId } ?? false + }.count + } + + // MARK: - AC8: 60s memoization + expiry + + /// `resolveConfig(experienceId:)` for the SAME id: a second resolution issued BEFORE the + /// injected clock crosses the 60s TTL reuses the memoized config (exactly ONE fetch total); + /// a second resolution issued AFTER the clock crosses 60s treats the memo as expired and + /// issues a NEW fetch (two fetches total). Parameterized over the two clock-advance amounts + /// so the resolve-twice-and-count logic lives once (SonarQube new-code-duplication + /// discipline) instead of two near-identical bodies. Every case ALSO asserts the on-disk + /// config cache is never touched by the exp path, memoized or not. + @Test( + "resolveConfig memoizes within the 60s TTL and treats the memo as expired past it", + arguments: [ + (advanceSeconds: 30.0, expectedFetchCount: 1), + (advanceSeconds: 61.0, expectedFetchCount: 2) + ] + ) + func resolveConfigMemoizesWithinTTLAndExpiresAfter( + advanceSeconds: Double, + expectedFetchCount: Int + ) async throws { + let sut = makeSUT() + let experienceId = "123" + + let first = await sut.previewState.resolveConfig(experienceId: experienceId) + sut.clock.setNow(Date(timeIntervalSince1970: 0).addingTimeInterval(advanceSeconds)) + let second = await sut.previewState.resolveConfig(experienceId: experienceId) + + #expect(first != nil) + #expect(second != nil) + let requests = await sut.httpClient.requests + #expect(fetchCount(for: experienceId, in: requests) == expectedFetchCount) + // The exp path never writes to the on-disk config cache — memoized or freshly fetched. + await #expect(throws: (any Error).self) { + _ = try await sut.fileStore.read(from: sut.cacheURL) + } + } + + // MARK: - Per-experienceId independence + + /// Two DIFFERENT `experienceId`s are memoized independently: resolving `"A"` does not + /// consult or consume the memo entry for `"B"`, and a repeat resolution of either (still + /// within the TTL) reuses ITS OWN memoized entry rather than triggering a fetch for the + /// other id. + @Test("resolveConfig memoizes each experienceId independently") + func resolveConfigMemoizesEachExperienceIdIndependently() async throws { + let sut = makeSUT() + + _ = await sut.previewState.resolveConfig(experienceId: "A") + _ = await sut.previewState.resolveConfig(experienceId: "B") + _ = await sut.previewState.resolveConfig(experienceId: "A") + _ = await sut.previewState.resolveConfig(experienceId: "B") + + let requests = await sut.httpClient.requests + #expect(fetchCount(for: "A", in: requests) == 1) + #expect(fetchCount(for: "B", in: requests) == 1) + } + + // MARK: - Expired-entry sweep + + /// Accessing the memo after an entry has expired SWEEPS (removes) it, bounding growth — + /// asserted via the internal `memoCount` introspection: after resolving `"A"` the memo holds + /// exactly one entry; once the clock advances past the 60s TTL, resolving a DIFFERENT id + /// `"B"` sweeps `"A"`'s now-expired entry on that same access, so the memo again holds + /// exactly one entry (only `"B"`'s fresh one) rather than accumulating both. + @Test("resolveConfig sweeps expired memo entries on access, bounding growth") + func resolveConfigSweepsExpiredEntriesOnAccess() async throws { + let sut = makeSUT() + + _ = await sut.previewState.resolveConfig(experienceId: "A") + let countAfterA = await sut.previewState.memoCount + #expect(countAfterA == 1) + + sut.clock.setNow(Date(timeIntervalSince1970: 0).addingTimeInterval(61)) + _ = await sut.previewState.resolveConfig(experienceId: "B") + let countAfterSweep = await sut.previewState.memoCount + + #expect(countAfterSweep == 1) + } +} diff --git a/Tests/ConvertSwiftSDKTests/PreviewZeroTraceTests.swift b/Tests/ConvertSwiftSDKTests/PreviewZeroTraceTests.swift new file mode 100644 index 0000000..08b1597 --- /dev/null +++ b/Tests/ConvertSwiftSDKTests/PreviewZeroTraceTests.swift @@ -0,0 +1,385 @@ +// Tests/ConvertSwiftSDKTests/PreviewZeroTraceTests.swift +// +// RED phase (qs-02 IOS-6 — zero-trace hardening, AC6 HARD GATE). Spec: +// `_bmad-output/planning-artifacts/2026-06-09-convert-ios-sdk/qs-02-experiment-preview.md`, +// contract §2 "Zero-trace (hard requirement)"; AC6 (zero trace) with AC7 (isolation) as a +// companion regression guard. +// +// ── What GREEN must add (does not exist yet — this is why this suite is RED) ─────────────────── +// A per-context `previewActive` gate (per the bd task ai-driven-product-dev-y1ke and the IOS-6 +// readiness assessment in `work/2026-07-08-ios-sdk-experiment-preview/workflow-state.yaml`) that: +// * ANDs into the `enableTracking` value `ConvertContext.runExperience`/`runExperiences` thread +// into `experienceManager.selectVariation`/`selectVariations` +// (`Sources/ConvertSwiftSDK/ConvertContext.swift:304`/`:430`) — so `BucketingManager`'s +// enableTracking-gated enqueue (`Sources/ConvertSwiftSDKCore/Bucketing/BucketingManager.swift:126-128`, +// `:214-216`) never fires for ANY experience run on a preview-active context, not just the +// forced target. +// * Adds an EARLY guard in `ConvertContext.trackConversion` +// (`Sources/ConvertSwiftSDK/ConvertContext.swift:586-661`) BEFORE +// `decisionStore.markGoalTriggeredIfNeeded(goalId:forVisitorKey:)` (`:618`, which itself +// unconditionally persists via `DecisionStore.swift:181`'s `fileStore.write` even on a FIRST +// trigger) and `return`s, so the dedup persist AND both `eventSink.enqueue` call sites (`:642`, +// `:659`) never run under preview. +// * Suppresses `ExperienceManager.selectVariation`'s sticky-decision WRITE +// (`Sources/ConvertSwiftSDKCore/Experience/ExperienceManager.swift:184-186`, +// `decisionStore.saveDecision` → `DecisionStore.swift:126-147`'s `fileStore.write` at `:146`) +// for OTHER (non-target) experiences run on a preview-active context — via a threaded +// `persistDecision: Bool` or a per-context scratch `DecisionStore` (GREEN's call; this suite is +// agnostic to which shape is picked, since it only asserts the OBSERVABLE zero-write contract +// through the injected `MockFileStore` spy). +// None of this exists today: `runExperience`/`runExperiences` gate ONLY on +// `sdk.isTrackingEnabled() && enableTracking` (the SDK-shared / per-call flags — not +// `previewActive`), and `ExperienceManager.selectVariation`'s `saveDecision` call has no gate at +// all. So EVERY assertion below observes real production traffic today and is expected to FAIL. +// +// ── Why a REAL `EventQueue` + REAL `CoordinatedFileEventQueueStore` (not `MockEventSink`) ────── +// AC6 explicitly requires the ON-DISK queue store be exercised (bd task design note: "Exercise the +// coordinated-file queue store for the on-disk assertion"). Mirrors the wiring +// `Support/TestFixtures.swift:648-660` (`makeQueueWithTempFileAndUploader`) already establishes: a +// REAL `EventQueue` over a REAL `CoordinatedFileEventQueueStore` at a UUID-named temp file, shipping +// through a `MockEventUploader` spy — this suite builds that same shape inline (not via the shared +// factory) so it also keeps a direct handle on the `CoordinatedFileEventQueueStore` for the +// `load()` on-disk assertion (the shared factory only returns the queue + its file URL). +// +// ── "Background-flush transition" — a documented, narrower choice than the full `LifecycleObserver` +// dance (flagged for the report) ───────────────────────────────────────────────────────────────── +// The only existing test that drives an actual app-lifecycle background transition is +// `Tests/ConvertSwiftSDKTests/Lifecycle/LifecycleObserverTests.swift` (posts +// `UIApplication.willResignActiveNotification`/`didBecomeActiveNotification` to an isolated +// `NotificationCenter` feeding a real `LifecycleObserver`). That observer's engine does exactly two +// things on a background transition (`Sources/ConvertSwiftSDK/Lifecycle/LifecycleObserver.swift:75,89`): +// `await eventQueue.persistBeforeBackground()` then (on foreground) `await eventQueue.flush()` — both +// `package`-accessible `EventQueue` entry points (`Sources/ConvertSwiftSDKCore/Event/EventQueue.swift:243,357`) +// already called DIRECTLY by an existing test +// (`Tests/ConvertSwiftSDKTests/Integration/FullChainIntegrationTests.swift:288`, `await sut.queue.flush()`). +// This suite calls those SAME two methods directly on the SAME real +// `EventQueue` the SDK is wired with, rather than re-standing-up the full `LifecycleObserver` + +// `MockBackgroundSessionManager` + UIKit `beginBackgroundTask` machinery: it exercises the identical +// production code path the observer drives, with a deterministic `await` (no notification-driven +// detached `Task` to race), which is what lets the on-disk zero-entries assertion run BEFORE `flush()` +// clears the store. `enqueueUpload` itself is NOT asserted here — `LifecycleEngine.handleBackground()` +// calls it UNCONDITIONALLY on every backgrounding regardless of preview state (an existing, orthogonal +// Story 5.3 design fact, not something IOS-6 changes) — so "zero background-session uploads" is +// asserted as "the file a background upload would stream is empty / was never written", per the AC6 +// design note's own phrasing ("nothing enqueued implies nothing to upload"). +import Testing +import Foundation +@testable import ConvertSwiftSDK + +@Suite("Preview zero-trace hardening (qs-02 IOS-6, AC6)") +@MainActor +struct PreviewZeroTraceTests { + // MARK: - Fixed fixture identifiers (single owner each — SonarQube 3% new-duplicated-lines gate) + + private static let accountId = "acc-preview-zt" + private static let projectId = "proj-preview-zt" + private static let targetExperienceId = "9101" + private static let targetKey = "target-key" + private static let targetForcedVariationId = "t-var" + private static let otherKey = "other-key" + private static let otherVariationId = "o-var" + private static let goalKey = "purchase-goal" + private static let goalId = "goal-pz" + + // MARK: - Config fixture + + /// A `ProjectConfig` carrying the preview TARGET experience (two variations — `"t-ctrl"` at 100% + /// traffic, `"t-var"` at 0%, so a natural bucket would NEVER select the forced one), a sibling + /// `"other-key"` experience (one 100%-traffic variation, buckets EVERY visitor deterministically — + /// same shape as `TestFixtures.makeExperienceConfig`), and one resolvable goal — all under the + /// SAME `account_id`/`project.id` so the sticky store key is well-formed. `throws` only on + /// malformed JSON (`ProjectConfig.init(from:)` degrades per-field, so this shape never throws). + private static func makeConfig() throws -> ProjectConfig { + let targetVariations = #"[{"id":"t-ctrl","key":"control","traffic_allocation":100},"# + + #"{"id":"\#(targetForcedVariationId)","key":"variant","traffic_allocation":0}]"# + let target = #"{"id":"\#(targetExperienceId)","key":"\#(targetKey)","type":"a/b","# + + #""audiences":[],"locations":[],"variations":\#(targetVariations)}"# + let otherVariation = #"{"id":"\#(otherVariationId)","key":"control","traffic_allocation":100}"# + let other = #"{"id":"9102","key":"\#(otherKey)","type":"a/b","# + + #""audiences":[],"locations":[],"variations":[\#(otherVariation)]}"# + let goal = #"{"id":"\#(goalId)","key":"\#(goalKey)","name":"Purchase","type":"advanced"}"# + let ids = #""account_id":"\#(accountId)","project":{"id":"\#(projectId)"}"# + let envelope = #"{\#(ids),"experiences":[\#(target),\#(other)],"goals":[\#(goal)]}"# + return try JSONDecoder().decode(ProjectConfig.self, from: Data(envelope.utf8)) + } + + /// The REAL on-disk path `DecisionStore.resolveStoreURL()` computes (`DecisionStore.swift:296-307` + /// — a `private static` method, so this replicates its documented Application-Support-first + /// algorithm rather than reaching it). Deterministic within a process, so it is a stable key into + /// each test's OWN fresh `MockFileStore` instance (no cross-test collision — a `MockFileStore` is + /// an in-memory actor keyed by `URL.absoluteString`, never a real file on disk). + private static func decisionStoreFileURL() throws -> URL { + let appSupport = try FileManager.default.url( + for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true + ) + return appSupport.appendingPathComponent("convert-decision-store.json") + } + + // MARK: - SUT + + /// Everything one test drives and asserts on: a READY SDK wired to a REAL `EventQueue` over a + /// REAL `CoordinatedFileEventQueueStore` at a UUID-named temp file (shipping through a + /// `MockEventUploader` spy), and the SDK's canonical `DecisionStore` over a `MockFileStore` spy — + /// so both the "in-memory queue" / "on-disk queue" / "background-session upload" surfaces AND the + /// "sticky-bucketing write" surface are independently observable. A named struct (not a tuple) + /// keeps the `large_tuple` lint rule satisfied. `Sendable` — every member is `Sendable` + /// (`ConvertSwiftSDK`, the `EventQueue`/`CoordinatedFileEventQueueStore`/`DecisionStore` actors, + /// the `MockEventUploader`/`MockFileStore` actors, `URL`). + private struct SUT: Sendable { + let sdk: ConvertSwiftSDK + let queue: EventQueue + let queueStoreURL: URL + let queueStore: CoordinatedFileEventQueueStore + let uploader: MockEventUploader + let decisionFileStore: MockFileStore + } + + /// Builds an isolated SUT: a fresh UUID-named temp file backs a REAL + /// `CoordinatedFileEventQueueStore` feeding a REAL `EventQueue` (over a `MockEventUploader` + /// spy — mirrors `TestFixtures.makeQueueWithTempFileAndUploader`'s wiring inline so this file + /// also keeps a direct `queueStore` handle for the on-disk `load()` assertion), and a fresh + /// `DecisionStore` over a `MockFileStore` spy. Single construction path so no case re-inlines the + /// queue/store wiring (SonarQube 3% gate). + private func makeSUT() async throws -> SUT { + let queueStoreURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString + ".json") + let queueStore = CoordinatedFileEventQueueStore(fileURL: queueStoreURL, logger: NoopLogger()) + let uploader = MockEventUploader() + let queue = EventQueue( + accountId: Self.accountId, + projectId: Self.projectId, + uploader: uploader, + eventBus: EventBus(), + store: queueStore + ) + let decisionFileStore = MockFileStore() + let decisionStore = DecisionStore(logger: NoopLogger(), fileStore: decisionFileStore) + let sdk = ConvertSwiftSDK( + configuration: ConvertConfiguration(sdkKey: "zero-trace-key"), + configProvider: MockConfigProvider.ungated(cached: nil, live: try Self.makeConfig()), + eventSink: queue, + logger: NoopLogger(), + decisionStore: decisionStore + ) + try await sdk.ready() + return SUT( + sdk: sdk, + queue: queue, + queueStoreURL: queueStoreURL, + queueStore: queueStore, + uploader: uploader, + decisionFileStore: decisionFileStore + ) + } + + // MARK: - `.bucketing` observer capture (qs-02 Fix 1 — mirrors + // `ConversionTrackingTests.firesConversionSystemEvent`'s `sdk.on(.conversion)` + `LockedBox` + // pattern, applied to `.bucketing`, so no test invents a new bus-observation mechanism) + + /// Subscribes a `.bucketing` fire-count counter on `sdk`'s bus, returning the counter and the + /// token to `off` when the caller is done. `EventBus.fire` delivers each callback as an + /// independent `MainActor` task, so callers `await MainActor.run { }` (the same barrier + /// `ConversionTrackingTests` uses) before reading the count. Centralized so no test in this file + /// re-spells the subscribe wiring (SonarQube 3% gate). + private func subscribeBucketingCount(on sdk: ConvertSwiftSDK) async -> (LockedBox, EventListenerToken) { + let fired = LockedBox(0) + let token = await sdk.on(.bucketing) { _ in fired.withLock { $0 += 1 } } + return (fired, token) + } + + // MARK: - AC6: zero trace across the full preview lifecycle incl. a background transition + + /// The AC6 hard gate: on a PREVIEW context, run the forced target, run a SIBLING (non-previewed) + /// experience, attempt a conversion, then drive the SAME background-persist / foreground-flush + /// steps a real backgrounding triggers (see the file header for why these are called directly + /// rather than through the full `LifecycleObserver` + `NotificationCenter` dance). Asserts all + /// four zero-trace surfaces the spec names: nothing ever reaches the uploader (in-memory + + /// background-session upload), nothing is ever persisted to the on-disk event-queue file, and + /// nothing is ever written to the sticky-decision file. `other?.id == otherVariationId` is the + /// AC7-adjacent "coherent rendering" companion: the sibling experience must still DECIDE normally + /// even though nothing about that decision may be tracked or persisted. Also asserts (qs-02 Fix 1, + /// JS parity — `context.ts`'s `if (!this._preview)` guard around every `SystemEvents.BUCKETING` + /// emit) that the `.bucketing` OBSERVER event never fires for EITHER the target or the sibling + /// while preview is active. + @Test("preview lifecycle incl. background transition produces zero tracking + zero sticky writes") + func previewLifecycleProducesZeroTrace() async throws { + let sut = try await makeSUT() + defer { try? FileManager.default.removeItem(at: sut.queueStoreURL) } + let context = sut.sdk.createContext(visitorId: "preview-visitor") + let (bucketingFired, bucketingToken) = await subscribeBucketingCount(on: sut.sdk) + + await context.setPreview(experienceId: Self.targetExperienceId, variationId: Self.targetForcedVariationId) + + let forced = await context.runExperience(Self.targetKey) + #expect(forced?.id == Self.targetForcedVariationId, "the preview target must still force correctly") + + let other = await context.runExperience(Self.otherKey) + #expect( + other?.id == Self.otherVariationId, + "a non-previewed sibling must still decide normally (coherent rendering, contract §2)" + ) + + await MainActor.run { } + #expect( + bucketingFired.get == 0, + "zero-trace (Fix 1): the .bucketing observer event must not fire for the target or the sibling" + ) + + await context.trackConversion(Self.goalKey, goalData: [.amount: .double(9.99)]) + + // Background transition: the SAME two `package`-accessible EventQueue entry points + // `LifecycleObserver` calls on `willResignActive`/`didBecomeActive` + // (`LifecycleObserver.swift:75,89`) — called directly here for a deterministic `await` + // (see file header). `persistBeforeBackground()` is a no-op when the buffer is empty + // (`EventQueue.swift:244`'s `guard !buffer.isEmpty else { return }`), so checking the + // on-disk store IMMEDIATELY after it — before `flush()` — proves whether anything EVER + // reached the in-memory buffer in the first place. + await sut.queue.persistBeforeBackground() + let persisted = try await sut.queueStore.load() + #expect(persisted.isEmpty, "zero-trace: nothing must ever be persisted to the on-disk event queue") + #expect( + !FileManager.default.fileExists(atPath: sut.queueStoreURL.path), + "zero-trace: no queue file was ever written — nothing exists for a background upload to stream" + ) + + // Foreground-recovery flush: drains disk-first-merged-with-buffer and ships through the + // uploader. If nothing was ever buffered or persisted, this must be a genuine no-op. + await sut.queue.flush() + #expect(await sut.uploader.callCount == 0, "zero-trace: no batch must ever reach the uploader") + #expect(await sut.uploader.uploadedBatches().isEmpty) + + // Zero sticky-bucketing writes: `ExperienceManager.selectVariation`'s `saveDecision` call + // (`ExperienceManager.swift:184-186`) and `trackConversion`'s `markGoalTriggeredIfNeeded` + // (`ConvertContext.swift:618`, itself persisting via `DecisionStore.swift:181`) both write + // through the SAME injected `MockFileStore` — so a `nil` there proves NEITHER ever fired. + let decisionURL = try Self.decisionStoreFileURL() + #expect( + await sut.decisionFileStore.contents(at: decisionURL) == nil, + "zero-trace: no sticky-decision / goal-dedup write must ever land on disk" + ) + await sut.sdk.off(bucketingToken) + } + + // MARK: - AC6: the bulk `runExperiences` path must also produce zero `.bucketing` observer events + + /// Companion to ``previewLifecycleProducesZeroTrace()`` for the BULK path (qs-02 Fix 1): under + /// preview, `runExperiences()` must still resolve BOTH the forced target (appended back per + /// `ConvertContext.runExperiences(enableTracking:)`'s contract) and the sibling (coherent + /// rendering), while firing ZERO `.bucketing` observer events for either. + @Test("preview-active runExperiences produces zero .bucketing observer events (Fix 1, bulk path)") + func previewRunExperiencesProducesZeroBucketingEvents() async throws { + let sut = try await makeSUT() + defer { try? FileManager.default.removeItem(at: sut.queueStoreURL) } + let context = sut.sdk.createContext(visitorId: "preview-bulk-visitor") + let (bucketingFired, bucketingToken) = await subscribeBucketingCount(on: sut.sdk) + + await context.setPreview(experienceId: Self.targetExperienceId, variationId: Self.targetForcedVariationId) + let results = await context.runExperiences() + + #expect( + results.first(where: { $0.experienceKey == Self.targetKey })?.id == Self.targetForcedVariationId, + "the forced target must still be present in the bulk results" + ) + #expect( + results.first(where: { $0.experienceKey == Self.otherKey })?.id == Self.otherVariationId, + "the sibling must still decide normally (coherent rendering, contract §2)" + ) + + await MainActor.run { } + #expect( + bucketingFired.get == 0, + "zero-trace (Fix 1): runExperiences must not fire the .bucketing observer event under preview" + ) + await sut.sdk.off(bucketingToken) + } + + // MARK: - AC7 companion: a non-preview context must still track + persist normally + + /// Regression guard (bd task design note + prompt requirement): a context that NEVER calls + /// `setPreview` must decide, ENQUEUE, and PERSIST exactly as before — proving whatever gate GREEN + /// adds is keyed on the PER-CONTEXT `previewActive` flag, not the SDK-shared tracking state (which + /// would silently break every non-preview caller). A FRESH, ISOLATED `SUT` (own queue/decision + /// store) — sharing this SUT with the zero-trace test above would let a genuine enqueue collide + /// with that test's "must stay empty" assertions. Also asserts (Fix 1 regression guard) that the + /// `.bucketing` OBSERVER event still fires normally when preview is NOT active — proving the new + /// `emitBucketing` gate is keyed on `previewActive`, not a blanket suppression. + @Test("a concurrent non-preview context still enqueues and persists normally (AC7 regression guard)") + func nonPreviewContextStillTracksAndPersists() async throws { + let sut = try await makeSUT() + defer { try? FileManager.default.removeItem(at: sut.queueStoreURL) } + let context = sut.sdk.createContext(visitorId: "normal-visitor") + let (bucketingFired, bucketingToken) = await subscribeBucketingCount(on: sut.sdk) + + let other = await context.runExperience(Self.otherKey) + #expect(other?.id == Self.otherVariationId) + await MainActor.run { } + #expect(bucketingFired.get == 1, "a non-preview .bucketing observer event must still fire normally") + await sut.sdk.off(bucketingToken) + await context.trackConversion(Self.goalKey, goalData: [.amount: .double(9.99)]) + + await sut.queue.persistBeforeBackground() + let persisted = try await sut.queueStore.load() + #expect(!persisted.isEmpty, "a non-preview context's events must still reach the on-disk queue") + + await sut.queue.flush() + #expect(await sut.uploader.callCount > 0, "a non-preview context's batch must still reach the uploader") + + let decisionURL = try Self.decisionStoreFileURL() + #expect( + await sut.decisionFileStore.contents(at: decisionURL) != nil, + "a non-preview context's sticky decision / goal-dedup mark must still be written" + ) + } + + // MARK: - AC6 gap: segment setters are NOT currently preview-gated (RED) + + /// `ConvertContext.setDefaultSegments`/`setCustomSegments` (`ConvertContext.swift:678`,`:708`) + /// delegate to `SegmentsManager.setDefaultSegments`/`setCustomSegments` + /// (`SegmentsManager.swift:34`,`:60`), which delegate to `DecisionStore.mergeSegments` + /// (`DecisionStore.swift:232`) and `DecisionStore.appendCustomSegments` (`DecisionStore.swift:267`) + /// — BOTH of which unconditionally `fileStore.write` (`DecisionStore.swift:258`,`:287`) with NO + /// preview gate today, unlike the sticky-decision write and the goal-dedup write this suite's + /// first test already covers. Confirms preview is genuinely active first (a forced `runExperience` + /// result), then calls both segment setters and asserts ZERO writes reached the shared + /// `MockFileStore` spy — expected to FAIL (RED) until GREEN adds the same per-context + /// `previewActive` gate to these two call sites. + @Test("preview-active context's segment setters produce zero sticky-store writes (AC6 gap)") + func previewContextSegmentSettersProduceZeroTrace() async throws { + let sut = try await makeSUT() + defer { try? FileManager.default.removeItem(at: sut.queueStoreURL) } + let context = sut.sdk.createContext(visitorId: "preview-segments-visitor") + + await context.setPreview(experienceId: Self.targetExperienceId, variationId: Self.targetForcedVariationId) + let forced = await context.runExperience(Self.targetKey) + #expect(forced?.id == Self.targetForcedVariationId, "preview must be genuinely active for this context") + + await context.setDefaultSegments(["country": "US"]) + await context.setCustomSegments(["vip"]) + + let decisionURL = try Self.decisionStoreFileURL() + #expect( + await sut.decisionFileStore.contents(at: decisionURL) == nil, + "zero-trace: setDefaultSegments/setCustomSegments must not write to the sticky-decision store under preview" + ) + } + + /// Companion regression guard (AC7): the SAME two setters, called on a NON-preview context, must + /// still persist — proving the future fix scopes the gate to `previewActive`, not a blanket + /// disable of the segment write path. A FRESH, ISOLATED `SUT`, matching + /// ``nonPreviewContextStillTracksAndPersists()``'s isolation rationale above. + @Test("a non-preview context's segment setters still persist normally (AC7 regression guard)") + func nonPreviewContextSegmentSettersStillPersist() async throws { + let sut = try await makeSUT() + defer { try? FileManager.default.removeItem(at: sut.queueStoreURL) } + let context = sut.sdk.createContext(visitorId: "normal-segments-visitor") + + await context.setDefaultSegments(["country": "US"]) + await context.setCustomSegments(["vip"]) + + let decisionURL = try Self.decisionStoreFileURL() + #expect( + await sut.decisionFileStore.contents(at: decisionURL) != nil, + "a non-preview context's segment setters must still write to the sticky-decision store" + ) + } +}