From 792bf67a10a930fe513e98f2aa46c4fdf5766701 Mon Sep 17 00:00:00 2001 From: Matus Tomlein Date: Wed, 7 Dec 2022 11:33:53 +0100 Subject: [PATCH] Add tests using Micro for payload validation (close #736) PR #738 --- .github/workflows/build.yml | 23 +- .slather.yml | 4 - Sources/Core/Tracker/StateFuture.swift | 27 +-- Sources/Core/Tracker/StateManager.swift | 2 +- Sources/Core/Tracker/TrackerState.swift | 2 +- Sources/Snowplow/Events/SelfDescribing.swift | 5 + .../TestRemoteConfiguration.swift | 4 + .../Integration/TestTrackEventsToMicro.swift | 199 ++++++++++++++++ Tests/TestNetworkConnection.swift | 6 +- Tests/Utils/Micro.swift | 213 ++++++++++++++++++ 10 files changed, 460 insertions(+), 25 deletions(-) delete mode 100644 .slather.yml create mode 100644 Tests/Integration/TestTrackEventsToMicro.swift create mode 100644 Tests/Utils/Micro.swift diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index efda17635..41bd94af9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -59,6 +59,26 @@ jobs: - name: Checkout uses: actions/checkout@v3 + # -- Micro -- + - name: Cache Micro + id: cache-micro + uses: actions/cache@v3 + with: + path: micro.jar + key: ${{ runner.os }}-micro + + - name: Get micro + if: steps.cache-micro.outputs.cache-hit != 'true' + run: curl -o micro.jar -L https://github.com/snowplow-incubator/snowplow-micro/releases/download/micro-1.3.4/snowplow-micro-1.3.4.jar + + - name: Run Micro in background + run: java -jar micro.jar & + + - name: Wait on Micro endpoint + timeout-minutes: 2 + run: while ! nc -z '0.0.0.0' 9090; do sleep 1; done + # -- Micro -- + - name: Select Xcode Version run: sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode-version }}.app/Contents/Developer @@ -68,7 +88,8 @@ jobs: -scheme SnowplowTracker \ -sdk "${{ matrix.sdk }}" \ -destination "${{ matrix.destination }}" \ - clean test | xcpretty + -quiet \ + clean test build_objc_demo_app: name: "ObjC demo (iOS ${{ matrix.version.ios }})" diff --git a/.slather.yml b/.slather.yml deleted file mode 100644 index 4be050905..000000000 --- a/.slather.yml +++ /dev/null @@ -1,4 +0,0 @@ -coverage_service: coveralls -xcodeproj: Snowplow.xcodeproj -workspace: Snowplow.xcworkspace -scheme: Snowplow-iOS diff --git a/Sources/Core/Tracker/StateFuture.swift b/Sources/Core/Tracker/StateFuture.swift index 8e20ebf28..91d65c485 100644 --- a/Sources/Core/Tracker/StateFuture.swift +++ b/Sources/Core/Tracker/StateFuture.swift @@ -27,19 +27,6 @@ import Foundation /// For this reason, the StateFuture can be the head of StateFuture chain which will collapse once the StateFuture /// head is asked to get the real state value. class StateFuture: NSObject { - var state: State? { - objc_sync_enter(self) - defer { objc_sync_exit(self) } - if computedState == nil { - if let stateMachine = stateMachine, let event = event { - computedState = stateMachine.transition(from: event, state: previousState?.state) - } - event = nil - previousState = nil - stateMachine = nil - } - return computedState - } private var event: Event? private var previousState: StateFuture? private var stateMachine: StateMachineProtocol? @@ -51,4 +38,18 @@ class StateFuture: NSObject { self.previousState = previousState self.stateMachine = stateMachine } + + func computeState() -> State? { + objc_sync_enter(self) + defer { objc_sync_exit(self) } + if computedState == nil { + if let stateMachine = stateMachine, let event = event { + computedState = stateMachine.transition(from: event, state: previousState?.computeState()) + previousState = nil + self.event = nil + self.stateMachine = nil + } + } + return computedState + } } diff --git a/Sources/Core/Tracker/StateManager.swift b/Sources/Core/Tracker/StateManager.swift index db750dd1e..19e6ad25f 100644 --- a/Sources/Core/Tracker/StateManager.swift +++ b/Sources/Core/Tracker/StateManager.swift @@ -100,7 +100,7 @@ class StateManager: NSObject { externally) Remove the early state-computation only when these two problems are fixed. */ - _ = currentStateFuture.state // Early state-computation + _ = currentStateFuture.computeState() // Early state-computation } } return trackerState.snapshot() diff --git a/Sources/Core/Tracker/TrackerState.swift b/Sources/Core/Tracker/TrackerState.swift index 245dc6bff..3947c13b5 100644 --- a/Sources/Core/Tracker/TrackerState.swift +++ b/Sources/Core/Tracker/TrackerState.swift @@ -58,7 +58,7 @@ class TrackerState: NSObject, TrackerStateSnapshot { // Protocol SPTrackerStateSnapshot func state(withIdentifier stateIdentifier: String) -> State? { - return stateFuture(withIdentifier: stateIdentifier)?.state + return stateFuture(withIdentifier: stateIdentifier)?.computeState() } func state(withStateMachine stateMachine: StateMachineProtocol) -> State? { diff --git a/Sources/Snowplow/Events/SelfDescribing.swift b/Sources/Snowplow/Events/SelfDescribing.swift index 5311ba3fa..b4f80a73b 100644 --- a/Sources/Snowplow/Events/SelfDescribing.swift +++ b/Sources/Snowplow/Events/SelfDescribing.swift @@ -59,4 +59,9 @@ public class SelfDescribing: SelfDescribingAbstract { self._schema = schema self._payload = payload } + + public init(schema: String, payload: [String : String]) { + self._schema = schema + self._payload = payload as [String : NSObject] + } } diff --git a/Tests/Configurations/TestRemoteConfiguration.swift b/Tests/Configurations/TestRemoteConfiguration.swift index f8eb16cb3..a223de00c 100644 --- a/Tests/Configurations/TestRemoteConfiguration.swift +++ b/Tests/Configurations/TestRemoteConfiguration.swift @@ -24,6 +24,10 @@ import Mocker @testable import SnowplowTracker class TestRemoteConfiguration: XCTestCase { + override func tearDown() { + Mocker.removeAll() + } + func testJSONToConfigurations() { let config = """ {"$schema":"http://iglucentral.com/schemas/com.snowplowanalytics.mobile/remote_config/jsonschema/1-0-0","configurationVersion":12,"configurationBundle": [\ diff --git a/Tests/Integration/TestTrackEventsToMicro.swift b/Tests/Integration/TestTrackEventsToMicro.swift new file mode 100644 index 000000000..7f4195b8e --- /dev/null +++ b/Tests/Integration/TestTrackEventsToMicro.swift @@ -0,0 +1,199 @@ +// +// TestWithMicro.swift +// Snowplow-iOSTests +// +// Copyright (c) 2013-2022 Snowplow Analytics Ltd. All rights reserved. +// +// This program is licensed to you under the Apache License Version 2.0, +// and you may not use this file except in compliance with the Apache License +// Version 2.0. You may obtain a copy of the Apache License Version 2.0 at +// http://www.apache.org/licenses/LICENSE-2.0. +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the Apache License Version 2.0 is distributed on +// an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +// express or implied. See the Apache License Version 2.0 for the specific +// language governing permissions and limitations there under. +// +// Authors: Michael Hadam +// License: Apache License Version 2.0 +// + +import XCTest +import SnowplowTracker + +class TestTrackEventsToMicro: XCTestCase { + var tracker: TrackerController? + + override func setUp() { + tracker = Snowplow.createTracker(namespace: "ns", network: NetworkConfiguration(endpoint: Micro.endpoint))! + + Micro.setUpMockerIgnores() + wait(for: [Micro.reset()], timeout: Micro.timeout) + } + + func testTrackStructuredEvent() { + let event = Structured(category: "shop", action: "add-to-basket") + event.label = "Add To Basket" + event.property = "pcs" + event.value = 2.0 + track(event) + + wait(for: [ + Micro.expectCounts(good: 1), + Micro.expectPrimitiveEvent() { actual in + XCTAssertEqual("shop", actual.se_category) + XCTAssertEqual("add-to-basket", actual.se_action) + XCTAssertEqual("Add To Basket", actual.se_label) + XCTAssertEqual("pcs", actual.se_property) + XCTAssertEqual(2.0, actual.se_value) + } + ], timeout: Micro.timeout) + } + + func testTrackSelfDescribing() { + let event = SelfDescribing( + schema: "iglu:com.snowplowanalytics.snowplow/screen_view/jsonschema/1-0-0", + payload: [ + "name": "test", "id": "something else" + ] + ) + track(event) + + wait(for: [ + Micro.expectCounts(good: 1), + Micro.expectSelfDescribingEvent() { (actual: ScreenViewExpected) in + XCTAssertEqual("test", actual.name) + XCTAssertEqual("something else", actual.id) + } + ], timeout: Micro.timeout) + } + + func testTrackScreenViews() { + // track the first screen view + track(ScreenView(name: "screen1", screenId: UUID())) + wait(for: [Micro.expectCounts(good: 1)], timeout: Micro.timeout) + wait(for: [Micro.reset()], timeout: Micro.timeout) + + // track the second screen view and check reference to previous + track(ScreenView(name: "screen2", screenId: UUID())) + wait(for: [ + Micro.expectCounts(good: 1), + Micro.expectSelfDescribingEvent() { (actual: ScreenViewExpected) in + XCTAssertEqual("screen2", actual.name) + XCTAssertEqual("screen1", actual.previousName) + } + ], timeout: Micro.timeout) + wait(for: [Micro.reset()], timeout: Micro.timeout) + + // track another event and check screen context + track(Timing(category: "cat", variable: "var", timing: 10)) + wait(for: [ + Micro.expectEventContext( + schema: "iglu:com.snowplowanalytics.mobile/screen/jsonschema/1-0-0" + ) { (actual: ScreenContextExpected) in + XCTAssertEqual("screen2", actual.name) + } + ], timeout: Micro.timeout) + } + + func testTrackDeepLink() { + // track the deep link received event + let deepLink = DeepLinkReceived(url: "https://snowplow.io") + deepLink.referrer = "https://plowsnow.io" + track(deepLink) + wait(for: [ + Micro.expectSelfDescribingEvent() { (actual: DeepLinkExpected) in + XCTAssertEqual("https://snowplow.io", actual.url) + XCTAssertEqual("https://plowsnow.io", actual.referrer) + } + ], timeout: Micro.timeout) + wait(for: [Micro.reset()], timeout: Micro.timeout) + + // track a screen view and check references to the deep link + track(ScreenView(name: "screen", screenId: UUID())) + wait(for: [ + // deep link info in payload + Micro.expectPrimitiveEvent() { actual in + XCTAssertEqual("https://snowplow.io", actual.page_url) + XCTAssertEqual("https://plowsnow.io", actual.page_referrer) + }, + // deep link info in context entity + Micro.expectEventContext( + schema: "iglu:com.snowplowanalytics.mobile/deep_link/jsonschema/1-0-0" + ) { (actual: DeepLinkExpected) in + XCTAssertEqual("https://snowplow.io", actual.url) + XCTAssertEqual("https://plowsnow.io", actual.referrer) + } + ], timeout: Micro.timeout) + } + + func testSessionTracking() { + // track the first event + track(Structured(category: "cat", action: "act")) + var userId: String?, sessionId: String? + wait(for: [ + Micro.expectEventContext( + schema: "iglu:com.snowplowanalytics.snowplow/client_session/jsonschema/1-0-2" + ) { (actual: SessionExpected) in + userId = actual.userId + sessionId = actual.sessionId + } + ], timeout: Micro.timeout) + wait(for: [Micro.reset()], timeout: Micro.timeout) + + // track the second event in the same session + track(Structured(category: "cat", action: "act")) + wait(for: [ + Micro.expectEventContext( + schema: "iglu:com.snowplowanalytics.snowplow/client_session/jsonschema/1-0-2" + ) { (actual: SessionExpected) in + XCTAssertEqual(userId, actual.userId) + XCTAssertEqual(sessionId, actual.sessionId) + } + ], timeout: Micro.timeout) + wait(for: [Micro.reset()], timeout: Micro.timeout) + + // start a new session and track event + tracker!.session!.startNewSession() + track(Structured(category: "cat", action: "act")) + wait(for: [ + Micro.expectEventContext( + schema: "iglu:com.snowplowanalytics.snowplow/client_session/jsonschema/1-0-2" + ) { (actual: SessionExpected) in + XCTAssertEqual(userId, actual.userId) + XCTAssertNotEqual(sessionId, actual.sessionId) + } + ], timeout: Micro.timeout) + } + + private func track(_ event: Event) { + _ = tracker!.track(event) + tracker!.emitter!.flush() + } +} + +struct ScreenViewExpected: Codable { + let name: String + let id: String + let type: String? + let previousName: String? + let previousId: String? + let previousType: String? + let transitionType: String? +} + +struct ScreenContextExpected: Codable { + let name: String + let id: String +} + +struct DeepLinkExpected: Codable { + let url: String + let referrer: String? +} + +struct SessionExpected: Codable { + let sessionId: String + let userId: String +} diff --git a/Tests/TestNetworkConnection.swift b/Tests/TestNetworkConnection.swift index 6db11ed64..bb2f563c3 100644 --- a/Tests/TestNetworkConnection.swift +++ b/Tests/TestNetworkConnection.swift @@ -26,13 +26,9 @@ import XCTest let TEST_URL_ENDPOINT = "acme.test.url.com" class TestNetworkConnection: XCTestCase { - override func setUp() { - super.setUp() - Mocker.removeAll() - } - override func tearDown() { super.tearDown() + Mocker.removeAll() } #if !os(watchOS) // Mocker seems not to currently work on watchOS diff --git a/Tests/Utils/Micro.swift b/Tests/Utils/Micro.swift new file mode 100644 index 000000000..ae9c981db --- /dev/null +++ b/Tests/Utils/Micro.swift @@ -0,0 +1,213 @@ +// +// Micro.swift +// Snowplow-iOSTests +// +// Copyright (c) 2013-2022 Snowplow Analytics Ltd. All rights reserved. +// +// This program is licensed to you under the Apache License Version 2.0, +// and you may not use this file except in compliance with the Apache License +// Version 2.0. You may obtain a copy of the Apache License Version 2.0 at +// http://www.apache.org/licenses/LICENSE-2.0. +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the Apache License Version 2.0 is distributed on +// an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +// express or implied. See the Apache License Version 2.0 for the specific +// language governing permissions and limitations there under. +// +// Authors: Michael Hadam +// License: Apache License Version 2.0 +// + +import Foundation +import XCTest +import Mocker + +class Micro { + + static let timeout = 10.0 + static let retryDelay = 0.5 + static let maxNumberOfRetries = 20 + static let endpoint = "http://0.0.0.0:9090" + + class func setUpMockerIgnores() { + Mocker.ignore(URL(string: "\(endpoint)/micro/good")!) + Mocker.ignore(URL(string: "\(endpoint)/micro/reset")!) + Mocker.ignore(URL(string: "\(endpoint)/micro/all")!) + Mocker.ignore(URL(string: "\(endpoint)/com.snowplowanalytics.snowplow/tp2")!) + Mocker.ignore(URL(string: "\(endpoint)/i")!) + } + + class func reset() -> XCTestExpectation { + let expectation = XCTestExpectation(description: "Reset Micro") + let url = URLRequest(url: URL(string: "\(endpoint)/micro/reset")!) + let task = URLSession.shared.dataTask(with: url) { data, response, error in + if let error = error { + XCTFail("Failed to reset Micro: \(error).") + } else { + expectation.fulfill() + } + } + task.resume() + return expectation + } + + class func expectCounts( + good: Int = 0, + bad: Int = 0, + expectation: XCTestExpectation? = nil, + numberOfRetries: Int = 0) -> XCTestExpectation { + let expectation = expectation ?? XCTestExpectation(description: "Count of good and bad events") + + let url = URLRequest(url: URL(string: "\(endpoint)/micro/all")!) + let task = URLSession.shared.dataTask(with: url) { data, response, error in + if let error = error { + XCTFail("Failed to request Micro: \(error).") + } else if let data = data, + let res = try? JSONDecoder().decode(AllResponse.self, from: data) { + if res.good == good && res.bad == bad { + expectation.fulfill() + } else if numberOfRetries < maxNumberOfRetries { + DispatchQueue.main.asyncAfter(deadline: .now() + retryDelay) { + _ = expectCounts(good: good, + bad: bad, + expectation: expectation, + numberOfRetries: numberOfRetries + 1) + } + } else { + XCTFail("Didn't find the expected event counts in Micro") + } + } else { + XCTFail("Failed to parse response from Micro") + } + } + task.resume() + + return expectation + } + + class func expectSelfDescribingEvent(numberOfRetries: Int = 0, + completion: @escaping (T)->()) -> XCTestExpectation { + return expectEvent() { (event: SelfDescribingResponse) in + completion(event.unstruct_event.data.data) + } + } + + class func expectPrimitiveEvent(numberOfRetries: Int = 0, + completion: @escaping (PrimitiveResponse)->()) -> XCTestExpectation { + return expectEvent() { (event: PrimitiveResponse) in + completion(event) + } + } + + class func expectEventContext(schema: String, + completion: @escaping (T)->()) -> XCTestExpectation { + return expectEvent() { (event: WithContextResponse) in + if let entity = event.contexts.data.filter({ $0.schema == schema }).map({ $0.data! }).first { + completion(entity) + } else { + XCTFail("Failed to find the context entity in response") + } + } + } + + private class func expectEvent(expectation: XCTestExpectation? = nil, + numberOfRetries: Int = 0, + completion: @escaping (T)->()) -> XCTestExpectation { + let expectation = expectation ?? XCTestExpectation(description: "Expected event") + + DispatchQueue.main.asyncAfter(deadline: .now() + retryDelay) { + let url = URLRequest(url: URL(string: "\(endpoint)/micro/good")!) + let task = URLSession.shared.dataTask(with: url) { data, response, error in + if let error = error { + XCTFail("Failed to request Micro: \(error).") + } else if let data = data { + if let items = try? JSONDecoder().decode([GoodResponse].self, from: data), + let item = items.first { + completion(item.event) + expectation.fulfill() + } else if numberOfRetries < maxNumberOfRetries { + _ = expectEvent(expectation: expectation, + numberOfRetries: numberOfRetries + 1, + completion: completion) + } else { + XCTFail("Didn't find the expected event in Micro") + } + } else { + XCTFail("Failed to parse response from Micro") + } + } + task.resume() + } + return expectation + } +} + +struct AllResponse: Codable { + let good: Int + let bad: Int +} + +struct PrimitiveResponse: Codable { + let se_category: String? + let se_action: String? + let se_label: String? + let se_property: String? + let se_value: Double? + let page_url: String? + let page_referrer: String? +} + +struct SelfDescribingResponse: Codable { + let unstruct_event: UnstructEventResponse +} + +struct UnstructEventResponse: Codable { + let data: SelfDescribingDataResponse +} + +struct ContextEntityWrapper: Decodable { + let entity: T? +} + +extension ContextEntityWrapper { + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + entity = try? container.decode(T.self) + } +} + +struct ContextEntityResponse: Codable { + let schema: String + let data: T? +} + +extension ContextEntityResponse { + private enum CodingKeys: String, CodingKey { + case schema = "schema" + case data = "data" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + schema = try container.decode(String.self, forKey: .schema) + data = try? container.decode(T.self, forKey: .data) + } +} + +struct ContextsResponse: Codable { + let data: [ContextEntityResponse] +} + +struct WithContextResponse: Codable { + let contexts: ContextsResponse +} + +struct SelfDescribingDataResponse: Codable { + let data: T +} + +struct GoodResponse: Codable { + let event: T +}