diff --git a/CHANGELOG.md b/CHANGELOG.md index c2bd9991f..935d2f194 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ ## Next +- fix: custom hosts with a path ([#290](https://github.com/PostHog/posthog-ios/pull/290)) - fix: identify macOS when running Mac Catalyst or iOS on Mac ([#287](https://github.com/PostHog/posthog-ios/pull/287)) ## 3.19.2 - 2025-01-30 diff --git a/PostHog.xcodeproj/project.pbxproj b/PostHog.xcodeproj/project.pbxproj index 476d5dbab..3ad33090f 100644 --- a/PostHog.xcodeproj/project.pbxproj +++ b/PostHog.xcodeproj/project.pbxproj @@ -129,6 +129,7 @@ DA5B85882CD21CBB00686389 /* AutocaptureEventProcessing.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5B85872CD21CBB00686389 /* AutocaptureEventProcessing.swift */; }; DA6B7C0B2D118C4E0024419F /* PostHog.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3AC745B5296D6FE60025C109 /* PostHog.framework */; }; DA6B7C0C2D118C4E0024419F /* PostHog.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3AC745B5296D6FE60025C109 /* PostHog.framework */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + DA6F24822D4A6CA100CA2777 /* PostHogApiTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA6F24812D4A6CA100CA2777 /* PostHogApiTest.swift */; }; DA7185482D07E11200396388 /* input_3.png in Resources */ = {isa = PBXBuildFile; fileRef = DA7185442D07E11200396388 /* input_3.png */; }; DA7185492D07E11200396388 /* input_2.png in Resources */ = {isa = PBXBuildFile; fileRef = DA7185432D07E11200396388 /* input_2.png */; }; DA71854A2D07E11200396388 /* output_1.webp in Resources */ = {isa = PBXBuildFile; fileRef = DA7185452D07E11200396388 /* output_1.webp */; }; @@ -565,6 +566,7 @@ DA26419A2CC0499300CB427B /* PostHogAutocaptureEventTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogAutocaptureEventTracker.swift; sourceTree = ""; }; DA5AA7132CE245CD004EFB99 /* UIApplication+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIApplication+.swift"; sourceTree = ""; }; DA5B85872CD21CBB00686389 /* AutocaptureEventProcessing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutocaptureEventProcessing.swift; sourceTree = ""; }; + DA6F24812D4A6CA100CA2777 /* PostHogApiTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogApiTest.swift; sourceTree = ""; }; DA7185422D07E11200396388 /* input_1.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = input_1.png; sourceTree = ""; }; DA7185432D07E11200396388 /* input_2.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = input_2.png; sourceTree = ""; }; DA7185442D07E11200396388 /* input_3.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = input_3.png; sourceTree = ""; }; @@ -930,6 +932,7 @@ 690FF0E22AEFD12900A0B06B /* PostHogConfigTest.swift */, 690FF0E82AEFD3BD00A0B06B /* PostHogQueueTest.swift */, 690FF0F42AF0F06100A0B06B /* PostHogSDKTest.swift */, + DA6F24812D4A6CA100CA2777 /* PostHogApiTest.swift */, DACF6D5C2CD2F5BC00F14133 /* PostHogAutocaptureIntegrationSpec.swift */, DA979D7A2CD370B700F56BAE /* PostHogAutocaptureEventTrackerSpec.swift */, 699C5FEE2C20242A007DB818 /* UUIDTest.swift */, @@ -1873,6 +1876,7 @@ 3A62647129CAF67B007E8C07 /* PostHogStorageManagerTest.swift in Sources */, 693E977D2C6257F9004B1030 /* ExampleSanitizer.swift in Sources */, 690FF0DF2AEFBC5700A0B06B /* PostHogLegacyQueueTest.swift in Sources */, + DA6F24822D4A6CA100CA2777 /* PostHogApiTest.swift in Sources */, 690FF0BD2AEF93F400A0B06B /* PostHogFeatureFlagsTest.swift in Sources */, 690FF0E92AEFD3BD00A0B06B /* PostHogQueueTest.swift in Sources */, 3AE3FB4B2993A68500AFFC18 /* PostHogStorageTest.swift in Sources */, diff --git a/PostHog/PostHogApi.swift b/PostHog/PostHogApi.swift index bf7dc6eb3..fc316ceb3 100644 --- a/PostHog/PostHogApi.swift +++ b/PostHog/PostHogApi.swift @@ -28,15 +28,33 @@ class PostHogApi { return config } - private func getURL(_ url: URL) -> URLRequest { + private func getURLRequest(_ url: URL) -> URLRequest { var request = URLRequest(url: url) request.httpMethod = "POST" request.timeoutInterval = defaultTimeout return request } + private func getEndpointURL( + _ endpoint: String, + queryItems: URLQueryItem..., + relativeTo baseUrl: URL + ) -> URL? { + guard var components = URLComponents( + url: baseUrl, + resolvingAgainstBaseURL: true + ) else { + return nil + } + let path = "\(components.path)/\(endpoint)" + .replacingOccurrences(of: "/+", with: "/", options: .regularExpression) + components.path = path + components.queryItems = queryItems + return components.url + } + func batch(events: [PostHogEvent], completion: @escaping (PostHogBatchUploadInfo) -> Void) { - guard let url = URL(string: "batch", relativeTo: config.host) else { + guard let url = getEndpointURL("/batch", relativeTo: config.host) else { hedgeLog("Malformed batch URL error.") return completion(PostHogBatchUploadInfo(statusCode: nil, error: nil)) } @@ -47,7 +65,7 @@ class PostHogApi { headers["Content-Encoding"] = "gzip" config.httpAdditionalHeaders = headers - let request = getURL(url) + let request = getURLRequest(url) let toSend: [String: Any] = [ "api_key": self.config.apiKey, @@ -93,7 +111,7 @@ class PostHogApi { } func snapshot(events: [PostHogEvent], completion: @escaping (PostHogBatchUploadInfo) -> Void) { - guard let url = URL(string: config.snapshotEndpoint, relativeTo: config.host) else { + guard let url = getEndpointURL(config.snapshotEndpoint, relativeTo: config.host) else { hedgeLog("Malformed snapshot URL error.") return completion(PostHogBatchUploadInfo(statusCode: nil, error: nil)) } @@ -108,7 +126,7 @@ class PostHogApi { headers["Content-Encoding"] = "gzip" config.httpAdditionalHeaders = headers - let request = getURL(url) + let request = getURLRequest(url) let toSend = events.map { $0.toJSON() } @@ -160,18 +178,18 @@ class PostHogApi { groups: [String: String], completion: @escaping ([String: Any]?, _ error: Error?) -> Void ) { - var urlComps = URLComponents() - urlComps.path = "/decide" - urlComps.queryItems = [URLQueryItem(name: "v", value: "3")] - - guard let url = urlComps.url(relativeTo: config.host) else { + guard let url = getEndpointURL( + "/decide", + queryItems: URLQueryItem(name: "v", value: "3"), + relativeTo: config.host + ) else { hedgeLog("Malformed decide URL error.") return completion(nil, nil) } let config = sessionConfig() - let request = getURL(url) + let request = getURLRequest(url) let toSend: [String: Any] = [ "api_key": self.config.apiKey, diff --git a/PostHogTests/PostHogApiTest.swift b/PostHogTests/PostHogApiTest.swift new file mode 100644 index 000000000..e75f0f0f5 --- /dev/null +++ b/PostHogTests/PostHogApiTest.swift @@ -0,0 +1,187 @@ +// +// PostHogApiTest.swift +// PostHog +// +// Created by Yiannis Josephides on 29/01/2025. +// + +import Foundation +import Testing + +@testable import PostHog + +@Suite(.serialized) +enum PostHogApiTests { + class BaseTestSuite { + var server: MockPostHogServer! + + init() { + server = MockPostHogServer() + server.start() + } + + deinit { + server.stop() + server = nil + } + + func getApiResponse( + apiCall: @escaping (@escaping (T) -> Void) -> Void + ) async -> T { + await withCheckedContinuation { continuation in + apiCall { resp in + continuation.resume(returning: resp) + } + } + } + + func testSnapshotEndpoint(forHost host: String) async throws { + let sut = getSut(host: host) + let resp = await getApiResponse { completion in + sut.snapshot(events: [], completion: completion) + } + + #expect(resp.error == nil) + #expect(resp.statusCode == 200) + } + + func testDecideEndpoint(forHost host: String) async throws { + let sut = getSut(host: host) + let resp = await getApiResponse { completion in + sut.decide(distinctId: "", anonymousId: "", groups: [:]) { data, _ in + completion(data) + } + } + + #expect(try #require(resp)["errorsWhileComputingFlags"] as! Bool == false) + } + + func testBatchEndpoint(forHost host: String) async throws { + let sut = getSut(host: host) + let resp = await getApiResponse { completion in + sut.batch(events: [], completion: completion) + } + + #expect(resp.error == nil) + #expect(resp.statusCode == 200) + } + + func getSut(host: String) -> PostHogApi { + PostHogApi(PostHogConfig(apiKey: "123", host: host)) + } + } + + @Suite("Test batch endpoint with different host paths") + class TestBatchEndpoint: BaseTestSuite { + @Test("with host containing no path") + func testHostWithNoPath() async throws { + try await testBatchEndpoint(forHost: "http://localhost") + } + + @Test("with host containing no path and trailing slash") + func testHostWithNoPathAndTrailingSlash() async throws { + try await testBatchEndpoint(forHost: "http://localhost/") + } + + @Test("with host containing path") + func testHostWithPath() async throws { + try await testBatchEndpoint(forHost: "http://localhost/api/v1") + } + + @Test("with host containing path and trailing slash") + func testHostWithPathAndTrailingSlash() async throws { + try await testBatchEndpoint(forHost: "http://localhost/api/v1/") + } + + @Test("with host containing port number") + func testHostWithPortNumber() async throws { + try await testBatchEndpoint(forHost: "http://localhost:9000") + } + + @Test("with host containing port number and path") + func testHostWithPortNumberAndPath() async throws { + try await testBatchEndpoint(forHost: "http://localhost:9000/api/v1") + } + + @Test("with host containing port number, path and trailing slash") + func testHostWithPortNumberAndTrailingSlash() async throws { + try await testBatchEndpoint(forHost: "http://localhost:9000/api/v1/") + } + } + + @Suite("Test snapshot endpoint with different host paths") + class TestSnapshotEndpoint: BaseTestSuite { + @Test("with host containing no path") + func testHostWithNoPath() async throws { + try await testSnapshotEndpoint(forHost: "http://localhost") + } + + @Test("with host containing no path and trailing slash") + func testHostWithNoPathAndTrailingSlash() async throws { + try await testSnapshotEndpoint(forHost: "http://localhost/") + } + + @Test("with host containing path") + func testHostWithPath() async throws { + try await testSnapshotEndpoint(forHost: "http://localhost/api/v1") + } + + @Test("with host containing path and trailing slash") + func testHostWithPathAndTrailingSlash() async throws { + try await testSnapshotEndpoint(forHost: "http://localhost/api/v1/") + } + + @Test("with host containing port number") + func testHostWithPortNumber() async throws { + try await testSnapshotEndpoint(forHost: "http://localhost:9000") + } + + @Test("with host containing port number and path") + func testHostWithPortNumberAndPath() async throws { + try await testSnapshotEndpoint(forHost: "http://localhost:9000/api/v1") + } + + @Test("with host containing port number, path and trailing slash") + func testHostWithPortNumberAndTrailingSlash() async throws { + try await testSnapshotEndpoint(forHost: "http://localhost:9000/api/v1/") + } + } + + @Suite("Test decide endpoint with different host paths") + class TestDecideEndpoint: BaseTestSuite { + @Test("with host containing no path") + func testHostWithNoPath() async throws { + try await testDecideEndpoint(forHost: "http://localhost") + } + + @Test("with host containing no path and trailing slash") + func testHostWithNoPathAndTrailingSlash() async throws { + try await testDecideEndpoint(forHost: "http://localhost/") + } + + @Test("with host containing path") + func testHostWithPath() async throws { + try await testDecideEndpoint(forHost: "http://localhost/api/v1") + } + + @Test("with host containing path and trailing slash") + func testHostWithPathAndTrailingSlash() async throws { + try await testDecideEndpoint(forHost: "http://localhost/api/v1/") + } + + @Test("with host containing port number") + func testHostWithPortNumber() async throws { + try await testDecideEndpoint(forHost: "http://localhost:9000") + } + + @Test("with host containing port number and path") + func testHostWithPortNumberAndPath() async throws { + try await testDecideEndpoint(forHost: "http://localhost:9000/api/v1") + } + + @Test("with host containing port number, path and trailing slash") + func testHostWithPortNumberAndTrailingSlash() async throws { + try await testDecideEndpoint(forHost: "http://localhost:9000/api/v1/") + } + } +} diff --git a/PostHogTests/PostHogSessionManagerTest.swift b/PostHogTests/PostHogSessionManagerTest.swift index 0f747b116..966f797b0 100644 --- a/PostHogTests/PostHogSessionManagerTest.swift +++ b/PostHogTests/PostHogSessionManagerTest.swift @@ -12,7 +12,7 @@ import Testing import XCTest @Suite(.serialized) -enum PostHogSessionManagerTests { +enum PostHogSessionManagerTest { @Suite("Test session id rotation logic") struct SessionRotation { let mockAppLifecycle: MockApplicationLifecyclePublisher diff --git a/PostHogTests/TestUtils/MockPostHogServer.swift b/PostHogTests/TestUtils/MockPostHogServer.swift index 4dbf75399..3ed8722fe 100644 --- a/PostHogTests/TestUtils/MockPostHogServer.swift +++ b/PostHogTests/TestUtils/MockPostHogServer.swift @@ -42,8 +42,8 @@ class MockPostHogServer { public var replayVariantName = "myBooleanRecordingFlag" public var replayVariantValue: Any = true - init(port _: Int = 9001) { - stub(condition: isPath("/decide")) { _ in + init() { + stub(condition: pathEndsWith("/decide")) { _ in var flags = [ "bool-value": true, "string-value": "test", @@ -90,7 +90,15 @@ class MockPostHogServer { return HTTPStubsResponse(jsonObject: obj, statusCode: 200, headers: nil) } - stub(condition: isPath("/batch")) { _ in + stub(condition: pathEndsWith("/batch")) { _ in + if self.return500 { + HTTPStubsResponse(jsonObject: [], statusCode: 500, headers: nil) + } else { + HTTPStubsResponse(jsonObject: ["status": "ok"], statusCode: 200, headers: nil) + } + } + + stub(condition: pathEndsWith("/s")) { _ in if self.return500 { HTTPStubsResponse(jsonObject: [], statusCode: 500, headers: nil) } else { @@ -99,9 +107,9 @@ class MockPostHogServer { } HTTPStubs.onStubActivation { request, _, _ in - if request.url?.path == "/batch" { + if request.url?.lastPathComponent == "batch" { self.trackBatchRequest(request) - } else if request.url?.path == "/decide" { + } else if request.url?.lastPathComponent == "decide" { self.trackDecide(request) } }