feat: initial public release
ConsentOS — a privacy-first cookie consent management platform. Self-hosted, source-available alternative to OneTrust, Cookiebot, and CookieYes. Full standards coverage (IAB TCF v2.2, GPP v1, Google Consent Mode v2, GPC, Shopify Customer Privacy API), multi-tenant architecture with role-based access, configuration cascade (system → org → group → site → region), dark-pattern detection in the scanner, and a tamper-evident consent record audit trail. This is the initial public release. Prior development history is retained internally. See README.md for the feature list, architecture overview, and quick-start instructions. Licensed under the Elastic Licence 2.0 — self-host freely; do not resell as a managed service.
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
import XCTest
|
||||
@testable import ConsentOSCore
|
||||
|
||||
// MARK: - Mock API
|
||||
|
||||
/// In-memory API implementation for testing without network calls.
|
||||
final class MockConsentAPI: ConsentAPIProtocol, @unchecked Sendable {
|
||||
|
||||
// MARK: - Configurable Behaviour
|
||||
|
||||
var configToReturn: ConsentConfig?
|
||||
var errorToThrow: Error?
|
||||
var postConsentError: Error?
|
||||
|
||||
// MARK: - Call Tracking
|
||||
|
||||
private(set) var fetchConfigCallCount = 0
|
||||
private(set) var postConsentCallCount = 0
|
||||
private(set) var lastPostedPayload: ConsentPayload?
|
||||
private(set) var lastFetchedSiteId: String?
|
||||
|
||||
func fetchConfig(siteId: String) async throws -> ConsentConfig {
|
||||
fetchConfigCallCount += 1
|
||||
lastFetchedSiteId = siteId
|
||||
if let error = errorToThrow { throw error }
|
||||
guard let config = configToReturn else {
|
||||
throw ConsentAPIError.unexpectedStatusCode(404)
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
func postConsent(_ payload: ConsentPayload) async throws {
|
||||
postConsentCallCount += 1
|
||||
lastPostedPayload = payload
|
||||
if let error = postConsentError { throw error }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Tests
|
||||
|
||||
final class ConsentAPITests: XCTestCase {
|
||||
|
||||
private var mockAPI: MockConsentAPI!
|
||||
private let siteId = "test-site-001"
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
mockAPI = MockConsentAPI()
|
||||
}
|
||||
|
||||
// MARK: - fetchConfig
|
||||
|
||||
func test_fetchConfig_returnsConfig_whenSuccessful() async throws {
|
||||
mockAPI.configToReturn = makeSampleConfig()
|
||||
|
||||
let result = try await mockAPI.fetchConfig(siteId: siteId)
|
||||
|
||||
XCTAssertEqual(result.siteId, siteId)
|
||||
XCTAssertEqual(mockAPI.fetchConfigCallCount, 1)
|
||||
XCTAssertEqual(mockAPI.lastFetchedSiteId, siteId)
|
||||
}
|
||||
|
||||
func test_fetchConfig_throwsError_onNetworkFailure() async {
|
||||
mockAPI.errorToThrow = ConsentAPIError.networkFailure(
|
||||
NSError(domain: "NSURLErrorDomain", code: -1009)
|
||||
)
|
||||
|
||||
do {
|
||||
_ = try await mockAPI.fetchConfig(siteId: siteId)
|
||||
XCTFail("Expected an error to be thrown")
|
||||
} catch ConsentAPIError.networkFailure {
|
||||
// Expected
|
||||
} catch {
|
||||
XCTFail("Unexpected error type: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func test_fetchConfig_throwsError_on404() async {
|
||||
mockAPI.errorToThrow = ConsentAPIError.unexpectedStatusCode(404)
|
||||
|
||||
do {
|
||||
_ = try await mockAPI.fetchConfig(siteId: siteId)
|
||||
XCTFail("Expected an error to be thrown")
|
||||
} catch ConsentAPIError.unexpectedStatusCode(let code) {
|
||||
XCTAssertEqual(code, 404)
|
||||
} catch {
|
||||
XCTFail("Unexpected error type: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - postConsent
|
||||
|
||||
func test_postConsent_sendsCorrectPayload() async throws {
|
||||
let consentedAt = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
let payload = ConsentPayload(
|
||||
siteId: siteId,
|
||||
visitorId: "visitor-xyz",
|
||||
accepted: [.analytics, .functional],
|
||||
rejected: [.marketing],
|
||||
consentedAt: consentedAt,
|
||||
bannerVersion: "v2",
|
||||
tcString: "test-tc-string"
|
||||
)
|
||||
|
||||
try await mockAPI.postConsent(payload)
|
||||
|
||||
XCTAssertEqual(mockAPI.postConsentCallCount, 1)
|
||||
let sent = try XCTUnwrap(mockAPI.lastPostedPayload)
|
||||
XCTAssertEqual(sent.siteId, siteId)
|
||||
XCTAssertEqual(sent.visitorId, "visitor-xyz")
|
||||
XCTAssertEqual(sent.platform, "ios")
|
||||
XCTAssertTrue(sent.accepted.contains("analytics"))
|
||||
XCTAssertTrue(sent.accepted.contains("functional"))
|
||||
XCTAssertTrue(sent.rejected.contains("marketing"))
|
||||
XCTAssertEqual(sent.bannerVersion, "v2")
|
||||
XCTAssertEqual(sent.tcString, "test-tc-string")
|
||||
}
|
||||
|
||||
func test_postConsent_platformAlwaysIOS() async throws {
|
||||
let payload = ConsentPayload(
|
||||
siteId: siteId,
|
||||
visitorId: "v",
|
||||
accepted: [],
|
||||
rejected: [],
|
||||
consentedAt: Date(),
|
||||
bannerVersion: nil
|
||||
)
|
||||
|
||||
try await mockAPI.postConsent(payload)
|
||||
|
||||
XCTAssertEqual(mockAPI.lastPostedPayload?.platform, "ios")
|
||||
}
|
||||
|
||||
func test_postConsent_throwsError_onFailure() async {
|
||||
mockAPI.postConsentError = ConsentAPIError.unexpectedStatusCode(500)
|
||||
|
||||
let payload = ConsentPayload(
|
||||
siteId: siteId,
|
||||
visitorId: "v",
|
||||
accepted: [],
|
||||
rejected: [],
|
||||
consentedAt: Date(),
|
||||
bannerVersion: nil
|
||||
)
|
||||
|
||||
do {
|
||||
try await mockAPI.postConsent(payload)
|
||||
XCTFail("Expected error")
|
||||
} catch ConsentAPIError.unexpectedStatusCode(let code) {
|
||||
XCTAssertEqual(code, 500)
|
||||
} catch {
|
||||
XCTFail("Unexpected error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ConsentPayload Serialisation
|
||||
|
||||
func test_consentPayload_encodesCategoriesToRawValues() throws {
|
||||
let payload = ConsentPayload(
|
||||
siteId: "s1",
|
||||
visitorId: "v1",
|
||||
accepted: [.analytics, .marketing],
|
||||
rejected: [.functional],
|
||||
consentedAt: Date(),
|
||||
bannerVersion: nil
|
||||
)
|
||||
|
||||
XCTAssertTrue(payload.accepted.contains("analytics"))
|
||||
XCTAssertTrue(payload.accepted.contains("marketing"))
|
||||
XCTAssertTrue(payload.rejected.contains("functional"))
|
||||
XCTAssertFalse(payload.accepted.contains("necessary"))
|
||||
}
|
||||
|
||||
// MARK: - Error Descriptions
|
||||
|
||||
func test_invalidURL_hasDescription() {
|
||||
let error = ConsentAPIError.invalidURL
|
||||
XCTAssertNotNil(error.errorDescription)
|
||||
XCTAssertFalse(error.errorDescription!.isEmpty)
|
||||
}
|
||||
|
||||
func test_unexpectedStatusCode_includesCodeInDescription() {
|
||||
let error = ConsentAPIError.unexpectedStatusCode(503)
|
||||
XCTAssertTrue(error.errorDescription?.contains("503") ?? false)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func makeSampleConfig() -> ConsentConfig {
|
||||
ConsentConfig(
|
||||
siteId: siteId,
|
||||
siteName: "Test Site",
|
||||
blockingMode: .optIn,
|
||||
consentExpiryDays: 365,
|
||||
bannerVersion: "v1",
|
||||
bannerConfig: ConsentConfig.BannerConfig(),
|
||||
categories: []
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import XCTest
|
||||
@testable import ConsentOSCore
|
||||
|
||||
final class ConsentCategoryTests: XCTestCase {
|
||||
|
||||
// MARK: - requiresConsent
|
||||
|
||||
func test_necessary_doesNotRequireConsent() {
|
||||
XCTAssertFalse(ConsentCategory.necessary.requiresConsent)
|
||||
}
|
||||
|
||||
func test_allOtherCategories_requireConsent() {
|
||||
let optionalCategories = ConsentCategory.allCases.filter { $0 != .necessary }
|
||||
for category in optionalCategories {
|
||||
XCTAssertTrue(category.requiresConsent, "\(category) should require consent")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - GCM Mappings
|
||||
|
||||
func test_necessary_hasNoGCMType() {
|
||||
XCTAssertNil(ConsentCategory.necessary.gcmConsentType)
|
||||
}
|
||||
|
||||
func test_functional_mapsToFunctionalityStorage() {
|
||||
XCTAssertEqual(ConsentCategory.functional.gcmConsentType, "functionality_storage")
|
||||
}
|
||||
|
||||
func test_analytics_mapsToAnalyticsStorage() {
|
||||
XCTAssertEqual(ConsentCategory.analytics.gcmConsentType, "analytics_storage")
|
||||
}
|
||||
|
||||
func test_marketing_mapsToAdStorage() {
|
||||
XCTAssertEqual(ConsentCategory.marketing.gcmConsentType, "ad_storage")
|
||||
}
|
||||
|
||||
func test_personalisation_mapsToPersonalizationStorage() {
|
||||
XCTAssertEqual(ConsentCategory.personalisation.gcmConsentType, "personalization_storage")
|
||||
}
|
||||
|
||||
// MARK: - TCF Purpose IDs
|
||||
|
||||
func test_necessary_hasNoTCFPurposes() {
|
||||
XCTAssertTrue(ConsentCategory.necessary.tcfPurposeIds.isEmpty)
|
||||
}
|
||||
|
||||
func test_functional_hasExpectedTCFPurposes() {
|
||||
XCTAssertEqual(ConsentCategory.functional.tcfPurposeIds, [1])
|
||||
}
|
||||
|
||||
func test_analytics_hasExpectedTCFPurposes() {
|
||||
XCTAssertEqual(ConsentCategory.analytics.tcfPurposeIds, [7, 8, 9, 10])
|
||||
}
|
||||
|
||||
func test_marketing_hasExpectedTCFPurposes() {
|
||||
XCTAssertEqual(ConsentCategory.marketing.tcfPurposeIds, [2, 3, 4])
|
||||
}
|
||||
|
||||
func test_personalisation_hasExpectedTCFPurposes() {
|
||||
XCTAssertEqual(ConsentCategory.personalisation.tcfPurposeIds, [5, 6])
|
||||
}
|
||||
|
||||
// MARK: - Display Names
|
||||
|
||||
func test_allCategories_haveNonEmptyDisplayNames() {
|
||||
for category in ConsentCategory.allCases {
|
||||
XCTAssertFalse(category.displayName.isEmpty, "\(category) missing display name")
|
||||
XCTAssertFalse(category.displayDescription.isEmpty, "\(category) missing description")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Codable
|
||||
|
||||
func test_category_roundTripsViaJSON() throws {
|
||||
for category in ConsentCategory.allCases {
|
||||
let encoded = try JSONEncoder().encode(category)
|
||||
let decoded = try JSONDecoder().decode(ConsentCategory.self, from: encoded)
|
||||
XCTAssertEqual(decoded, category)
|
||||
}
|
||||
}
|
||||
|
||||
func test_category_decodesFromRawValue() throws {
|
||||
let json = #""analytics""#.data(using: .utf8)!
|
||||
let decoded = try JSONDecoder().decode(ConsentCategory.self, from: json)
|
||||
XCTAssertEqual(decoded, .analytics)
|
||||
}
|
||||
|
||||
// MARK: - CaseIterable
|
||||
|
||||
func test_allCases_containsFiveCategories() {
|
||||
XCTAssertEqual(ConsentCategory.allCases.count, 5)
|
||||
}
|
||||
|
||||
func test_allCases_containsExpectedMembers() {
|
||||
let expected: Set<ConsentCategory> = [
|
||||
.necessary, .functional, .analytics, .marketing, .personalisation
|
||||
]
|
||||
XCTAssertEqual(Set(ConsentCategory.allCases), expected)
|
||||
}
|
||||
}
|
||||
292
sdks/ios/ConsentOS/Tests/ConsentOSCoreTests/ConsentOSTests.swift
Normal file
292
sdks/ios/ConsentOS/Tests/ConsentOSCoreTests/ConsentOSTests.swift
Normal file
@@ -0,0 +1,292 @@
|
||||
import XCTest
|
||||
@testable import ConsentOSCore
|
||||
|
||||
// MARK: - Mock Storage
|
||||
|
||||
final class MockConsentStorage: ConsentStorageProtocol, @unchecked Sendable {
|
||||
|
||||
var storedState: ConsentState?
|
||||
var storedCachedConfig: CachedConfig?
|
||||
var storedVisitorId: String = UUID().uuidString
|
||||
|
||||
private(set) var saveStateCallCount = 0
|
||||
private(set) var clearStateCallCount = 0
|
||||
|
||||
func loadState() -> ConsentState? { storedState }
|
||||
|
||||
func saveState(_ state: ConsentState) {
|
||||
saveStateCallCount += 1
|
||||
storedState = state
|
||||
}
|
||||
|
||||
func clearState() {
|
||||
clearStateCallCount += 1
|
||||
storedState = nil
|
||||
}
|
||||
|
||||
func loadCachedConfig() -> CachedConfig? { storedCachedConfig }
|
||||
|
||||
func saveCachedConfig(_ cached: CachedConfig) {
|
||||
storedCachedConfig = cached
|
||||
}
|
||||
|
||||
func clearCachedConfig() {
|
||||
storedCachedConfig = nil
|
||||
}
|
||||
|
||||
func visitorId() -> String { storedVisitorId }
|
||||
}
|
||||
|
||||
// MARK: - Tests
|
||||
|
||||
final class ConsentOSTests: XCTestCase {
|
||||
|
||||
private var sdk: ConsentOS!
|
||||
private var mockStorage: MockConsentStorage!
|
||||
private var mockAPI: MockConsentAPI!
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
mockStorage = MockConsentStorage()
|
||||
mockAPI = MockConsentAPI()
|
||||
sdk = ConsentOS(storage: mockStorage, api: mockAPI)
|
||||
}
|
||||
|
||||
// MARK: - Configuration
|
||||
|
||||
func test_configure_setsIsConfigured() {
|
||||
XCTAssertFalse(sdk.isConfigured)
|
||||
sdk.configure(
|
||||
siteId: "site-001",
|
||||
apiBase: URL(string: "https://api.example.com")!
|
||||
)
|
||||
XCTAssertTrue(sdk.isConfigured)
|
||||
}
|
||||
|
||||
func test_configure_setsSiteId() {
|
||||
sdk.configure(siteId: "my-site", apiBase: URL(string: "https://api.example.com")!)
|
||||
XCTAssertEqual(sdk.siteId, "my-site")
|
||||
}
|
||||
|
||||
func test_configure_restoresPersistedState() {
|
||||
let existing = ConsentState(
|
||||
visitorId: mockStorage.storedVisitorId,
|
||||
accepted: [.analytics],
|
||||
rejected: [],
|
||||
consentedAt: Date(),
|
||||
bannerVersion: "v1"
|
||||
)
|
||||
mockStorage.storedState = existing
|
||||
|
||||
sdk.configure(siteId: "site-001", apiBase: URL(string: "https://api.example.com")!)
|
||||
|
||||
XCTAssertEqual(sdk.consentState?.accepted, [.analytics])
|
||||
}
|
||||
|
||||
func test_configure_createsNewState_whenNoPersistedState() {
|
||||
mockStorage.storedState = nil
|
||||
sdk.configure(siteId: "site-001", apiBase: URL(string: "https://api.example.com")!)
|
||||
|
||||
XCTAssertNotNil(sdk.consentState)
|
||||
XCTAssertFalse(sdk.consentState!.hasInteracted)
|
||||
}
|
||||
|
||||
// MARK: - shouldShowBanner
|
||||
|
||||
func test_shouldShowBanner_returnsTrue_whenNoInteraction() async {
|
||||
sdk.configure(siteId: "site-001", apiBase: URL(string: "https://api.example.com")!)
|
||||
// State has no interaction (consentedAt is nil)
|
||||
let shouldShow = await sdk.shouldShowBanner()
|
||||
XCTAssertTrue(shouldShow)
|
||||
}
|
||||
|
||||
func test_shouldShowBanner_returnsFalse_whenRecentConsentExists() async {
|
||||
mockAPI.configToReturn = makeSampleConfig(expiryDays: 365, bannerVersion: "v1")
|
||||
|
||||
let recent = ConsentState(
|
||||
visitorId: "v1",
|
||||
accepted: [.analytics],
|
||||
rejected: [.marketing, .functional, .personalisation],
|
||||
consentedAt: Date(), // just now
|
||||
bannerVersion: "v1"
|
||||
)
|
||||
mockStorage.storedState = recent
|
||||
// Pre-load cached config so shouldShowBanner sees it
|
||||
mockStorage.storedCachedConfig = CachedConfig(
|
||||
config: makeSampleConfig(expiryDays: 365, bannerVersion: "v1"),
|
||||
fetchedAt: Date()
|
||||
)
|
||||
|
||||
sdk.configure(siteId: "site-001", apiBase: URL(string: "https://api.example.com")!)
|
||||
|
||||
let shouldShow = await sdk.shouldShowBanner()
|
||||
XCTAssertFalse(shouldShow)
|
||||
}
|
||||
|
||||
func test_shouldShowBanner_returnsTrue_whenBannerVersionChanged() async {
|
||||
mockAPI.configToReturn = makeSampleConfig(expiryDays: 365, bannerVersion: "v2")
|
||||
|
||||
let state = ConsentState(
|
||||
visitorId: "v1",
|
||||
accepted: [.analytics],
|
||||
rejected: [],
|
||||
consentedAt: Date(),
|
||||
bannerVersion: "v1" // old version
|
||||
)
|
||||
mockStorage.storedState = state
|
||||
mockStorage.storedCachedConfig = CachedConfig(
|
||||
config: makeSampleConfig(expiryDays: 365, bannerVersion: "v2"),
|
||||
fetchedAt: Date()
|
||||
)
|
||||
|
||||
sdk.configure(siteId: "site-001", apiBase: URL(string: "https://api.example.com")!)
|
||||
|
||||
let shouldShow = await sdk.shouldShowBanner()
|
||||
XCTAssertTrue(shouldShow)
|
||||
}
|
||||
|
||||
func test_shouldShowBanner_returnsTrue_whenConsentExpired() async {
|
||||
let expiryDays = 30
|
||||
let consentedAt = Date(timeIntervalSinceNow: -Double(expiryDays * 86_400 + 1))
|
||||
|
||||
let state = ConsentState(
|
||||
visitorId: "v1",
|
||||
accepted: [.analytics],
|
||||
rejected: [],
|
||||
consentedAt: consentedAt,
|
||||
bannerVersion: "v1"
|
||||
)
|
||||
mockStorage.storedState = state
|
||||
mockStorage.storedCachedConfig = CachedConfig(
|
||||
config: makeSampleConfig(expiryDays: expiryDays, bannerVersion: "v1"),
|
||||
fetchedAt: Date()
|
||||
)
|
||||
|
||||
sdk.configure(siteId: "site-001", apiBase: URL(string: "https://api.example.com")!)
|
||||
|
||||
let shouldShow = await sdk.shouldShowBanner()
|
||||
XCTAssertTrue(shouldShow)
|
||||
}
|
||||
|
||||
// MARK: - acceptAll
|
||||
|
||||
func test_acceptAll_updatesConsentState() async {
|
||||
sdk.configure(siteId: "site-001", apiBase: URL(string: "https://api.example.com")!)
|
||||
await sdk.acceptAll()
|
||||
|
||||
let state = sdk.consentState
|
||||
XCTAssertNotNil(state)
|
||||
XCTAssertTrue(state!.hasInteracted)
|
||||
XCTAssertFalse(state!.accepted.isEmpty)
|
||||
}
|
||||
|
||||
func test_acceptAll_grantsAllOptionalCategories() async {
|
||||
sdk.configure(siteId: "site-001", apiBase: URL(string: "https://api.example.com")!)
|
||||
await sdk.acceptAll()
|
||||
|
||||
for category in ConsentCategory.allCases where category.requiresConsent {
|
||||
XCTAssertTrue(sdk.getConsentStatus(for: category), "\(category) should be granted")
|
||||
}
|
||||
}
|
||||
|
||||
func test_acceptAll_persistsToStorage() async {
|
||||
sdk.configure(siteId: "site-001", apiBase: URL(string: "https://api.example.com")!)
|
||||
await sdk.acceptAll()
|
||||
|
||||
XCTAssertGreaterThan(mockStorage.saveStateCallCount, 0)
|
||||
XCTAssertNotNil(mockStorage.storedState)
|
||||
}
|
||||
|
||||
func test_acceptAll_postsConsentToAPI() async {
|
||||
sdk.configure(siteId: "site-001", apiBase: URL(string: "https://api.example.com")!)
|
||||
await sdk.acceptAll()
|
||||
|
||||
XCTAssertEqual(mockAPI.postConsentCallCount, 1)
|
||||
XCTAssertEqual(mockAPI.lastPostedPayload?.platform, "ios")
|
||||
}
|
||||
|
||||
// MARK: - rejectAll
|
||||
|
||||
func test_rejectAll_updatesConsentState() async {
|
||||
sdk.configure(siteId: "site-001", apiBase: URL(string: "https://api.example.com")!)
|
||||
await sdk.rejectAll()
|
||||
|
||||
let state = sdk.consentState
|
||||
XCTAssertNotNil(state)
|
||||
XCTAssertTrue(state!.hasInteracted)
|
||||
XCTAssertTrue(state!.accepted.isEmpty)
|
||||
}
|
||||
|
||||
func test_rejectAll_deniesAllOptionalCategories() async {
|
||||
sdk.configure(siteId: "site-001", apiBase: URL(string: "https://api.example.com")!)
|
||||
await sdk.rejectAll()
|
||||
|
||||
for category in ConsentCategory.allCases where category.requiresConsent {
|
||||
XCTAssertFalse(sdk.getConsentStatus(for: category), "\(category) should be denied")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - acceptCategories
|
||||
|
||||
func test_acceptCategories_onlyGrantsSpecifiedCategories() async {
|
||||
sdk.configure(siteId: "site-001", apiBase: URL(string: "https://api.example.com")!)
|
||||
await sdk.acceptCategories([.analytics, .functional])
|
||||
|
||||
XCTAssertTrue(sdk.getConsentStatus(for: .analytics))
|
||||
XCTAssertTrue(sdk.getConsentStatus(for: .functional))
|
||||
XCTAssertFalse(sdk.getConsentStatus(for: .marketing))
|
||||
XCTAssertFalse(sdk.getConsentStatus(for: .personalisation))
|
||||
}
|
||||
|
||||
// MARK: - getConsentStatus
|
||||
|
||||
func test_getConsentStatus_returnsTrue_forNecessary_always() {
|
||||
sdk.configure(siteId: "site-001", apiBase: URL(string: "https://api.example.com")!)
|
||||
XCTAssertTrue(sdk.getConsentStatus(for: .necessary))
|
||||
}
|
||||
|
||||
func test_getConsentStatus_returnsFalse_forOptional_beforeInteraction() {
|
||||
sdk.configure(siteId: "site-001", apiBase: URL(string: "https://api.example.com")!)
|
||||
XCTAssertFalse(sdk.getConsentStatus(for: .analytics))
|
||||
}
|
||||
|
||||
// MARK: - Delegate
|
||||
|
||||
func test_delegate_isNotifiedAfterAcceptAll() async {
|
||||
class MockDelegate: ConsentOSDelegate {
|
||||
var didChangeCalled = false
|
||||
var receivedState: ConsentState?
|
||||
|
||||
func consentDidChange(_ state: ConsentState) {
|
||||
didChangeCalled = true
|
||||
receivedState = state
|
||||
}
|
||||
}
|
||||
|
||||
let delegate = MockDelegate()
|
||||
sdk.delegate = delegate
|
||||
sdk.configure(siteId: "site-001", apiBase: URL(string: "https://api.example.com")!)
|
||||
await sdk.acceptAll()
|
||||
|
||||
// Allow the MainActor dispatch to complete
|
||||
await Task.yield()
|
||||
await Task.yield()
|
||||
|
||||
XCTAssertTrue(delegate.didChangeCalled)
|
||||
XCTAssertNotNil(delegate.receivedState)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func makeSampleConfig(expiryDays: Int = 365, bannerVersion: String = "v1") -> ConsentConfig {
|
||||
ConsentConfig(
|
||||
siteId: "site-001",
|
||||
siteName: "Test",
|
||||
blockingMode: .optIn,
|
||||
consentExpiryDays: expiryDays,
|
||||
bannerVersion: bannerVersion,
|
||||
bannerConfig: ConsentConfig.BannerConfig(),
|
||||
categories: []
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import XCTest
|
||||
@testable import ConsentOSCore
|
||||
|
||||
final class ConsentStateTests: XCTestCase {
|
||||
|
||||
private let visitorId = "test-visitor-123"
|
||||
|
||||
// MARK: - Initial State
|
||||
|
||||
func test_newState_hasNoInteraction() {
|
||||
let state = ConsentState(visitorId: visitorId)
|
||||
XCTAssertFalse(state.hasInteracted)
|
||||
XCTAssertNil(state.consentedAt)
|
||||
XCTAssertTrue(state.accepted.isEmpty)
|
||||
XCTAssertTrue(state.rejected.isEmpty)
|
||||
}
|
||||
|
||||
func test_newState_preservesVisitorId() {
|
||||
let state = ConsentState(visitorId: visitorId)
|
||||
XCTAssertEqual(state.visitorId, visitorId)
|
||||
}
|
||||
|
||||
// MARK: - isGranted
|
||||
|
||||
func test_necessary_isAlwaysGranted() {
|
||||
let state = ConsentState(visitorId: visitorId) // no interaction
|
||||
XCTAssertTrue(state.isGranted(.necessary))
|
||||
}
|
||||
|
||||
func test_optional_isNotGranted_whenNoInteraction() {
|
||||
let state = ConsentState(visitorId: visitorId)
|
||||
for category in ConsentCategory.allCases where category != .necessary {
|
||||
XCTAssertFalse(state.isGranted(category), "\(category) should not be granted by default")
|
||||
}
|
||||
}
|
||||
|
||||
func test_accepted_category_isGranted() {
|
||||
let state = ConsentState(
|
||||
visitorId: visitorId,
|
||||
accepted: [.analytics],
|
||||
rejected: [],
|
||||
consentedAt: Date(),
|
||||
bannerVersion: nil
|
||||
)
|
||||
XCTAssertTrue(state.isGranted(.analytics))
|
||||
}
|
||||
|
||||
func test_rejected_category_isNotGranted() {
|
||||
let state = ConsentState(
|
||||
visitorId: visitorId,
|
||||
accepted: [],
|
||||
rejected: [.analytics],
|
||||
consentedAt: Date(),
|
||||
bannerVersion: nil
|
||||
)
|
||||
XCTAssertFalse(state.isGranted(.analytics))
|
||||
}
|
||||
|
||||
// MARK: - isDenied
|
||||
|
||||
func test_necessary_isNeverDenied() {
|
||||
let state = ConsentState(
|
||||
visitorId: visitorId,
|
||||
accepted: [],
|
||||
rejected: [.analytics],
|
||||
consentedAt: Date(),
|
||||
bannerVersion: nil
|
||||
)
|
||||
XCTAssertFalse(state.isDenied(.necessary))
|
||||
}
|
||||
|
||||
func test_rejected_category_isDenied() {
|
||||
let state = ConsentState(
|
||||
visitorId: visitorId,
|
||||
accepted: [],
|
||||
rejected: [.marketing],
|
||||
consentedAt: Date(),
|
||||
bannerVersion: nil
|
||||
)
|
||||
XCTAssertTrue(state.isDenied(.marketing))
|
||||
}
|
||||
|
||||
// MARK: - acceptingAll()
|
||||
|
||||
func test_acceptingAll_grantsAllOptionalCategories() {
|
||||
let state = ConsentState(visitorId: visitorId)
|
||||
let accepted = state.acceptingAll()
|
||||
|
||||
let expected = Set(ConsentCategory.allCases.filter { $0.requiresConsent })
|
||||
XCTAssertEqual(accepted.accepted, expected)
|
||||
XCTAssertTrue(accepted.rejected.isEmpty)
|
||||
}
|
||||
|
||||
func test_acceptingAll_setsConsentedAt() {
|
||||
let before = Date()
|
||||
let state = ConsentState(visitorId: visitorId).acceptingAll()
|
||||
XCTAssertNotNil(state.consentedAt)
|
||||
XCTAssertGreaterThanOrEqual(state.consentedAt!, before)
|
||||
}
|
||||
|
||||
func test_acceptingAll_preservesVisitorId() {
|
||||
let state = ConsentState(visitorId: visitorId).acceptingAll()
|
||||
XCTAssertEqual(state.visitorId, visitorId)
|
||||
}
|
||||
|
||||
func test_acceptingAll_hasInteracted() {
|
||||
let state = ConsentState(visitorId: visitorId).acceptingAll()
|
||||
XCTAssertTrue(state.hasInteracted)
|
||||
}
|
||||
|
||||
// MARK: - rejectingAll()
|
||||
|
||||
func test_rejectingAll_emptiesAccepted() {
|
||||
let state = ConsentState(visitorId: visitorId)
|
||||
let rejected = state.rejectingAll()
|
||||
XCTAssertTrue(rejected.accepted.isEmpty)
|
||||
}
|
||||
|
||||
func test_rejectingAll_rejectsAllOptionalCategories() {
|
||||
let state = ConsentState(visitorId: visitorId).rejectingAll()
|
||||
let expected = Set(ConsentCategory.allCases.filter { $0.requiresConsent })
|
||||
XCTAssertEqual(state.rejected, expected)
|
||||
}
|
||||
|
||||
func test_rejectingAll_setsConsentedAt() {
|
||||
let state = ConsentState(visitorId: visitorId).rejectingAll()
|
||||
XCTAssertNotNil(state.consentedAt)
|
||||
}
|
||||
|
||||
// MARK: - accepting(categories:)
|
||||
|
||||
func test_acceptingCategories_onlyAcceptsSpecified() {
|
||||
let state = ConsentState(visitorId: visitorId)
|
||||
let result = state.accepting(categories: [.analytics, .functional])
|
||||
|
||||
XCTAssertTrue(result.accepted.contains(.analytics))
|
||||
XCTAssertTrue(result.accepted.contains(.functional))
|
||||
XCTAssertFalse(result.accepted.contains(.marketing))
|
||||
XCTAssertFalse(result.accepted.contains(.personalisation))
|
||||
}
|
||||
|
||||
func test_acceptingCategories_rejectsRemainder() {
|
||||
let state = ConsentState(visitorId: visitorId)
|
||||
let result = state.accepting(categories: [.analytics])
|
||||
|
||||
XCTAssertTrue(result.rejected.contains(.marketing))
|
||||
XCTAssertTrue(result.rejected.contains(.functional))
|
||||
XCTAssertTrue(result.rejected.contains(.personalisation))
|
||||
}
|
||||
|
||||
func test_acceptingCategories_ignoresNecessary() {
|
||||
let state = ConsentState(visitorId: visitorId)
|
||||
// Passing .necessary should not land in accepted/rejected sets
|
||||
let result = state.accepting(categories: [.necessary])
|
||||
XCTAssertFalse(result.accepted.contains(.necessary))
|
||||
}
|
||||
|
||||
func test_acceptingEmptySet_rejectsAll() {
|
||||
let state = ConsentState(visitorId: visitorId)
|
||||
let result = state.accepting(categories: [])
|
||||
XCTAssertTrue(result.accepted.isEmpty)
|
||||
let expectedRejected = Set(ConsentCategory.allCases.filter { $0.requiresConsent })
|
||||
XCTAssertEqual(result.rejected, expectedRejected)
|
||||
}
|
||||
|
||||
// MARK: - Codable
|
||||
|
||||
func test_state_roundTripsViaJSON() throws {
|
||||
let original = ConsentState(
|
||||
visitorId: visitorId,
|
||||
accepted: [.analytics, .functional],
|
||||
rejected: [.marketing],
|
||||
consentedAt: Date(timeIntervalSince1970: 1_700_000_000),
|
||||
bannerVersion: "v2"
|
||||
)
|
||||
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
|
||||
let data = try encoder.encode(original)
|
||||
let decoded = try decoder.decode(ConsentState.self, from: data)
|
||||
|
||||
XCTAssertEqual(decoded.visitorId, original.visitorId)
|
||||
XCTAssertEqual(decoded.accepted, original.accepted)
|
||||
XCTAssertEqual(decoded.rejected, original.rejected)
|
||||
XCTAssertEqual(decoded.bannerVersion, original.bannerVersion)
|
||||
// Date round-trip: allow 1-second tolerance for ISO8601 sub-second truncation
|
||||
XCTAssertEqual(
|
||||
decoded.consentedAt!.timeIntervalSince1970,
|
||||
original.consentedAt!.timeIntervalSince1970,
|
||||
accuracy: 1.0
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Equatable
|
||||
|
||||
func test_twoStatesWithSameValues_areEqual() {
|
||||
let date = Date(timeIntervalSince1970: 1_000_000)
|
||||
let a = ConsentState(visitorId: visitorId, accepted: [.analytics], rejected: [], consentedAt: date, bannerVersion: "v1")
|
||||
let b = ConsentState(visitorId: visitorId, accepted: [.analytics], rejected: [], consentedAt: date, bannerVersion: "v1")
|
||||
XCTAssertEqual(a, b)
|
||||
}
|
||||
|
||||
func test_twoStatesWithDifferentAccepted_areNotEqual() {
|
||||
let date = Date(timeIntervalSince1970: 1_000_000)
|
||||
let a = ConsentState(visitorId: visitorId, accepted: [.analytics], rejected: [], consentedAt: date, bannerVersion: nil)
|
||||
let b = ConsentState(visitorId: visitorId, accepted: [.marketing], rejected: [], consentedAt: date, bannerVersion: nil)
|
||||
XCTAssertNotEqual(a, b)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import XCTest
|
||||
@testable import ConsentOSCore
|
||||
|
||||
final class ConsentStorageTests: XCTestCase {
|
||||
|
||||
// Use a unique suite per test run to avoid state bleed
|
||||
private var storage: ConsentStorage!
|
||||
private let suiteName = "com.cmp.tests.\(UUID().uuidString)"
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
storage = ConsentStorage(suiteName: suiteName)
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
// Clean up the UserDefaults suite after each test
|
||||
UserDefaults(suiteName: suiteName)?.removePersistentDomain(forName: suiteName)
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
// MARK: - Consent State
|
||||
|
||||
func test_loadState_returnsNil_whenNothingStored() {
|
||||
XCTAssertNil(storage.loadState())
|
||||
}
|
||||
|
||||
func test_saveAndLoadState_roundTrips() {
|
||||
let state = ConsentState(
|
||||
visitorId: "visitor-abc",
|
||||
accepted: [.analytics, .functional],
|
||||
rejected: [.marketing],
|
||||
consentedAt: Date(timeIntervalSince1970: 1_700_000_000),
|
||||
bannerVersion: "v3"
|
||||
)
|
||||
|
||||
storage.saveState(state)
|
||||
let loaded = storage.loadState()
|
||||
|
||||
XCTAssertNotNil(loaded)
|
||||
XCTAssertEqual(loaded?.visitorId, state.visitorId)
|
||||
XCTAssertEqual(loaded?.accepted, state.accepted)
|
||||
XCTAssertEqual(loaded?.rejected, state.rejected)
|
||||
XCTAssertEqual(loaded?.bannerVersion, state.bannerVersion)
|
||||
}
|
||||
|
||||
func test_clearState_removesStoredState() {
|
||||
let state = ConsentState(visitorId: "test-visitor")
|
||||
storage.saveState(state)
|
||||
storage.clearState()
|
||||
XCTAssertNil(storage.loadState())
|
||||
}
|
||||
|
||||
func test_saveState_overwritesPreviousState() {
|
||||
let state1 = ConsentState(visitorId: "visitor-1")
|
||||
let state2 = ConsentState(
|
||||
visitorId: "visitor-1",
|
||||
accepted: [.analytics],
|
||||
rejected: [],
|
||||
consentedAt: Date(),
|
||||
bannerVersion: "v2"
|
||||
)
|
||||
|
||||
storage.saveState(state1)
|
||||
storage.saveState(state2)
|
||||
|
||||
let loaded = storage.loadState()
|
||||
XCTAssertEqual(loaded?.accepted, [.analytics])
|
||||
}
|
||||
|
||||
// MARK: - Cached Config
|
||||
|
||||
func test_loadCachedConfig_returnsNil_whenNothingStored() {
|
||||
XCTAssertNil(storage.loadCachedConfig())
|
||||
}
|
||||
|
||||
func test_saveAndLoadCachedConfig_roundTrips() {
|
||||
let config = makeSampleConfig()
|
||||
let cached = CachedConfig(config: config, fetchedAt: Date())
|
||||
|
||||
storage.saveCachedConfig(cached)
|
||||
let loaded = storage.loadCachedConfig()
|
||||
|
||||
XCTAssertNotNil(loaded)
|
||||
XCTAssertEqual(loaded?.config.siteId, config.siteId)
|
||||
XCTAssertEqual(loaded?.config.bannerVersion, config.bannerVersion)
|
||||
}
|
||||
|
||||
func test_clearCachedConfig_removesStoredConfig() {
|
||||
let config = makeSampleConfig()
|
||||
let cached = CachedConfig(config: config, fetchedAt: Date())
|
||||
storage.saveCachedConfig(cached)
|
||||
storage.clearCachedConfig()
|
||||
XCTAssertNil(storage.loadCachedConfig())
|
||||
}
|
||||
|
||||
// MARK: - Cache TTL
|
||||
|
||||
func test_cachedConfig_isNotExpired_whenFetchedJustNow() {
|
||||
let cached = CachedConfig(config: makeSampleConfig(), fetchedAt: Date())
|
||||
XCTAssertFalse(cached.isExpired)
|
||||
}
|
||||
|
||||
func test_cachedConfig_isExpired_whenFetchedOverTTLAgo() {
|
||||
let pastDate = Date(timeIntervalSinceNow: -(CachedConfig.ttl + 1))
|
||||
let cached = CachedConfig(config: makeSampleConfig(), fetchedAt: pastDate)
|
||||
XCTAssertTrue(cached.isExpired)
|
||||
}
|
||||
|
||||
func test_cachedConfig_isNotExpired_whenFetchedJustBeforeTTL() {
|
||||
let almostExpired = Date(timeIntervalSinceNow: -(CachedConfig.ttl - 1))
|
||||
let cached = CachedConfig(config: makeSampleConfig(), fetchedAt: almostExpired)
|
||||
XCTAssertFalse(cached.isExpired)
|
||||
}
|
||||
|
||||
// MARK: - Visitor ID
|
||||
|
||||
func test_visitorId_isGeneratedAndPersisted() {
|
||||
let id1 = storage.visitorId()
|
||||
let id2 = storage.visitorId()
|
||||
XCTAssertEqual(id1, id2)
|
||||
XCTAssertFalse(id1.isEmpty)
|
||||
}
|
||||
|
||||
func test_visitorId_isAValidUUID() {
|
||||
let id = storage.visitorId()
|
||||
XCTAssertNotNil(UUID(uuidString: id), "Visitor ID should be a valid UUID")
|
||||
}
|
||||
|
||||
func test_visitorId_isDifferentAcrossFreshInstances() {
|
||||
let suite2 = "com.cmp.tests.\(UUID().uuidString)"
|
||||
let storage2 = ConsentStorage(suiteName: suite2)
|
||||
defer {
|
||||
UserDefaults(suiteName: suite2)?.removePersistentDomain(forName: suite2)
|
||||
}
|
||||
|
||||
let id1 = storage.visitorId()
|
||||
let id2 = storage2.visitorId()
|
||||
XCTAssertNotEqual(id1, id2)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func makeSampleConfig() -> ConsentConfig {
|
||||
ConsentConfig(
|
||||
siteId: "site-test-001",
|
||||
siteName: "Test Site",
|
||||
blockingMode: .optIn,
|
||||
consentExpiryDays: 365,
|
||||
bannerVersion: "v1",
|
||||
bannerConfig: ConsentConfig.BannerConfig(),
|
||||
categories: [
|
||||
ConsentConfig.CategoryConfig(
|
||||
key: "necessary",
|
||||
enabled: true,
|
||||
displayName: nil,
|
||||
description: nil
|
||||
),
|
||||
ConsentConfig.CategoryConfig(
|
||||
key: "analytics",
|
||||
enabled: true,
|
||||
displayName: nil,
|
||||
description: nil
|
||||
)
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import XCTest
|
||||
@testable import ConsentOSCore
|
||||
|
||||
final class TCFStringEncoderTests: XCTestCase {
|
||||
|
||||
// MARK: - Encoding Returns nil Before Interaction
|
||||
|
||||
func test_encode_returnsNil_whenStateHasNoInteraction() {
|
||||
let state = ConsentState(visitorId: "v1") // consentedAt is nil
|
||||
let config = makeSampleConfig()
|
||||
XCTAssertNil(TCFStringEncoder.encode(state: state, config: config))
|
||||
}
|
||||
|
||||
// MARK: - Encoding Returns a Non-Empty String After Interaction
|
||||
|
||||
func test_encode_returnsNonEmptyString_afterAcceptAll() {
|
||||
let state = ConsentState(visitorId: "v1").acceptingAll()
|
||||
let config = makeSampleConfig()
|
||||
let result = TCFStringEncoder.encode(state: state, config: config)
|
||||
XCTAssertNotNil(result)
|
||||
XCTAssertFalse(result!.isEmpty)
|
||||
}
|
||||
|
||||
func test_encode_returnsNonEmptyString_afterRejectAll() {
|
||||
let state = ConsentState(visitorId: "v1").rejectingAll()
|
||||
let config = makeSampleConfig()
|
||||
let result = TCFStringEncoder.encode(state: state, config: config)
|
||||
XCTAssertNotNil(result)
|
||||
XCTAssertFalse(result!.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - Base64url Format
|
||||
|
||||
func test_encode_producesValidBase64url() {
|
||||
let state = ConsentState(visitorId: "v1").acceptingAll()
|
||||
let config = makeSampleConfig()
|
||||
let tcString = TCFStringEncoder.encode(state: state, config: config)!
|
||||
|
||||
// Base64url must not contain standard Base64 characters that were replaced
|
||||
XCTAssertFalse(tcString.contains("+"), "TC string must not contain '+'")
|
||||
XCTAssertFalse(tcString.contains("/"), "TC string must not contain '/'")
|
||||
XCTAssertFalse(tcString.contains("="), "TC string must not contain padding '='")
|
||||
}
|
||||
|
||||
func test_encode_producesDecodableBase64() {
|
||||
let state = ConsentState(visitorId: "v1").acceptingAll()
|
||||
let config = makeSampleConfig()
|
||||
let tcString = TCFStringEncoder.encode(state: state, config: config)!
|
||||
|
||||
// Convert back to standard Base64 for decoding
|
||||
var base64 = tcString
|
||||
.replacingOccurrences(of: "-", with: "+")
|
||||
.replacingOccurrences(of: "_", with: "/")
|
||||
// Pad to multiple of 4
|
||||
let remainder = base64.count % 4
|
||||
if remainder != 0 {
|
||||
base64 += String(repeating: "=", count: 4 - remainder)
|
||||
}
|
||||
|
||||
let data = Data(base64Encoded: base64)
|
||||
XCTAssertNotNil(data, "TC string should be valid Base64")
|
||||
XCTAssertGreaterThan(data!.count, 0)
|
||||
}
|
||||
|
||||
// MARK: - Determinism
|
||||
|
||||
func test_encode_producesConsistentOutput_forSameInput() {
|
||||
// The encoder uses a fixed timestamp, so two calls with the same state
|
||||
// should produce strings of the same length (timestamps differ slightly).
|
||||
let consentDate = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
let state = ConsentState(
|
||||
visitorId: "v1",
|
||||
accepted: [.analytics, .marketing],
|
||||
rejected: [.functional, .personalisation],
|
||||
consentedAt: consentDate,
|
||||
bannerVersion: "v1"
|
||||
)
|
||||
let config = makeSampleConfig()
|
||||
|
||||
let result1 = TCFStringEncoder.encode(state: state, config: config)
|
||||
let result2 = TCFStringEncoder.encode(state: state, config: config)
|
||||
|
||||
XCTAssertEqual(result1, result2)
|
||||
}
|
||||
|
||||
// MARK: - Different States Produce Different Strings
|
||||
|
||||
func test_encode_producesDistinctStrings_forAcceptAllVsRejectAll() {
|
||||
let consentDate = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
let acceptedState = ConsentState(
|
||||
visitorId: "v1",
|
||||
accepted: Set(ConsentCategory.allCases.filter { $0.requiresConsent }),
|
||||
rejected: [],
|
||||
consentedAt: consentDate,
|
||||
bannerVersion: nil
|
||||
)
|
||||
let rejectedState = ConsentState(
|
||||
visitorId: "v1",
|
||||
accepted: [],
|
||||
rejected: Set(ConsentCategory.allCases.filter { $0.requiresConsent }),
|
||||
consentedAt: consentDate,
|
||||
bannerVersion: nil
|
||||
)
|
||||
let config = makeSampleConfig()
|
||||
|
||||
let tcAccepted = TCFStringEncoder.encode(state: acceptedState, config: config)
|
||||
let tcRejected = TCFStringEncoder.encode(state: rejectedState, config: config)
|
||||
|
||||
XCTAssertNotEqual(tcAccepted, tcRejected)
|
||||
}
|
||||
|
||||
// MARK: - Minimum Length
|
||||
|
||||
func test_encode_producesStringOfReasonableLength() {
|
||||
let state = ConsentState(visitorId: "v1").acceptingAll()
|
||||
let config = makeSampleConfig()
|
||||
let tcString = TCFStringEncoder.encode(state: state, config: config)!
|
||||
|
||||
// A valid core TC string serialises to at least ~20 bytes before Base64url encoding
|
||||
// (the first 6 bits alone carry the version). 28 chars is a reasonable lower bound.
|
||||
XCTAssertGreaterThan(tcString.count, 28, "TC string appears too short")
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func makeSampleConfig() -> ConsentConfig {
|
||||
ConsentConfig(
|
||||
siteId: "site-001",
|
||||
siteName: "Test Site",
|
||||
blockingMode: .optIn,
|
||||
consentExpiryDays: 365,
|
||||
bannerVersion: "v1",
|
||||
bannerConfig: ConsentConfig.BannerConfig(),
|
||||
categories: []
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user