import Foundation import Testing @testable import PediFoods /// All these tests set the shared `URLProtocolStub.handler` static, so they /// must not run concurrently with each other (see the note on /// `SessionStateStorePersistenceTests` for the same reasoning). @Suite(.serialized) struct FeatureControlServiceTests { @MainActor private func makeService(cacheTTL: TimeInterval = 60) -> FeatureControlService { let defaults = UserDefaults(suiteName: UUID().uuidString)! let session = URLProtocolStub.makeSession() return FeatureControlService(session: session, cacheTTL: cacheTTL, userDefaults: defaults) } private let bootstrapSuccessBody = Data(""" {"ok":true,"configVersion":3,"evaluatedAt":"2026-01-01T00:00:00Z","source":"live", "flags":{"at.promo":true},"raw":{"at.promo":{"enabled":true,"variant":"on"}}} """.utf8) @Test("evaluate returns a live snapshot on a successful bootstrap and caches it") @MainActor func evaluateSucceedsAndCaches() async { let service = makeService() URLProtocolStub.handler = { _ in (.stub(statusCode: 200), self.bootstrapSuccessBody) } defer { URLProtocolStub.handler = nil } let context = FeatureControlEvaluationContext(subjectType: "customer", subjectId: "u1", storeId: nil, attributes: [:]) let snapshot = await service.evaluate(context: context, jwt: nil) #expect(snapshot.source == "live") #expect(snapshot.configVersion == 3) #expect(snapshot.isEnabled("at.promo") == true) } @Test("evaluate serves from cache on a second call without hitting the network again") @MainActor func evaluateServesFromCacheOnSecondCall() async { let service = makeService() var requestCount = 0 URLProtocolStub.handler = { [bootstrapSuccessBody] _ in requestCount += 1 return (.stub(statusCode: 200), bootstrapSuccessBody) } defer { URLProtocolStub.handler = nil } let context = FeatureControlEvaluationContext(subjectType: "customer", subjectId: "u1", storeId: nil, attributes: [:]) _ = await service.evaluate(context: context, jwt: nil) let second = await service.evaluate(context: context, jwt: nil) #expect(requestCount == 1) #expect(second.source == "live") } @Test("evaluate falls back to a stale cache entry when the network call fails") @MainActor func evaluateFallsBackToCacheOnNetworkError() async { let service = makeService() let context = FeatureControlEvaluationContext(subjectType: "customer", subjectId: "u1", storeId: nil, attributes: [:]) URLProtocolStub.handler = { _ in (.stub(statusCode: 200), self.bootstrapSuccessBody) } _ = await service.evaluate(context: context, jwt: nil) URLProtocolStub.handler = { _ in (.stub(statusCode: 500), Data()) } defer { URLProtocolStub.handler = nil } let refreshed = await service.evaluate(context: context, jwt: nil, forceRefresh: true) #expect(refreshed.source == "cache_fallback") #expect(refreshed.isEnabled("at.promo") == true) } @Test("evaluate falls back to defaults when the network fails and there's no cache") @MainActor func evaluateFallsBackToDefaultsWithNoCache() async { let service = makeService() URLProtocolStub.handler = { _ in (.stub(statusCode: 500), Data()) } defer { URLProtocolStub.handler = nil } let context = FeatureControlEvaluationContext(subjectType: "customer", subjectId: "u1", storeId: nil, attributes: [:]) let snapshot = await service.evaluate(context: context, jwt: nil) #expect(snapshot.source == "defaults") #expect(snapshot.isEnabled("at.promo") == false) } @Test("evaluate re-fetches once a cached entry's TTL has expired") @MainActor func evaluateRefetchesAfterCacheExpires() async { let service = makeService(cacheTTL: -1) // already expired the instant it's written var requestCount = 0 URLProtocolStub.handler = { [bootstrapSuccessBody] _ in requestCount += 1 return (.stub(statusCode: 200), bootstrapSuccessBody) } defer { URLProtocolStub.handler = nil } let context = FeatureControlEvaluationContext(subjectType: "customer", subjectId: "u1", storeId: nil, attributes: [:]) _ = await service.evaluate(context: context, jwt: nil) _ = await service.evaluate(context: context, jwt: nil) #expect(requestCount == 2) } }