import Foundation import Testing @testable import PediFoods private func makeClient() -> ApiClient { ApiClient(session: URLProtocolStub.makeSession(), tokenStore: FakeTokenStore()) } private final class FakeTokenStore: TokenStore { var jwt: String? func clear() { jwt = nil } } // MARK: - shouldRetry @Test("shouldRetry is true for rate limiting and transport errors") func shouldRetryTrueForRateLimitAndTransport() { let client = makeClient() #expect(client.shouldRetry(.rateLimited(nil))) #expect(client.shouldRetry(.transportError("timeout"))) } @Test("shouldRetry is true only for 5xx http errors, not 4xx") func shouldRetryOnlyFor5xx() { let client = makeClient() #expect(client.shouldRetry(.httpError(500, nil))) #expect(client.shouldRetry(.httpError(503, nil))) #expect(client.shouldRetry(.httpError(404, nil)) == false) #expect(client.shouldRetry(.httpError(400, nil)) == false) } @Test("shouldRetry is false for terminal client-side errors") func shouldRetryFalseForTerminalErrors() { let client = makeClient() #expect(client.shouldRetry(.invalidURL) == false) #expect(client.shouldRetry(.unauthorized(nil)) == false) #expect(client.shouldRetry(.cancelled) == false) #expect(client.shouldRetry(.decodeError(nil)) == false) } // MARK: - backoff @Test("backoff honors a rate-limit response's Retry-After value in nanoseconds") func backoffHonorsRetryAfter() { let client = makeClient() #expect(client.backoff(for: 1, error: .rateLimited(2)) == 2_000_000_000) } @Test("backoff scales with attempt number and is capped at 2 seconds") func backoffScalesAndCaps() { let client = makeClient() let first = client.backoff(for: 1, error: .transportError("x")) let second = client.backoff(for: 2, error: .transportError("x")) #expect(second > first) #expect(client.backoff(for: 100, error: .transportError("x")) == 2_000_000_000) } // MARK: - buildURL @Test("buildURL appends the path to the base URL and preserves query items") func buildURLAppendsPathAndQuery() throws { let client = makeClient() let base = URL(string: "https://api.example.com")! let url = try client.buildURL(path: "/stores/1", query: [URLQueryItem(name: "lang", value: "pt")], baseURL: base) #expect(url.absoluteString == "https://api.example.com/stores/1?lang=pt") } @Test("buildURL omits the query string entirely when there are no query items") func buildURLOmitsEmptyQuery() throws { let client = makeClient() let url = try client.buildURL(path: "/stores/1", query: [], baseURL: URL(string: "https://api.example.com")!) #expect(url.absoluteString == "https://api.example.com/stores/1") } // MARK: - buildHeaders @Test("buildHeaders sends the customer JWT as a Bearer token when the request requires auth") func buildHeadersUsesCustomerJWT() { let store = FakeTokenStore() store.jwt = "customer-jwt" let client = ApiClient(session: URLProtocolStub.makeSession(), tokenStore: store) let request = ApiRequest(path: "/me", requiresAuth: true) #expect(client.buildHeaders(for: request)["Authorization"] == "Bearer customer-jwt") } @Test("buildHeaders prefers an explicit customBearerToken over the customer JWT") func buildHeadersPrefersCustomBearerToken() { let store = FakeTokenStore() store.jwt = "customer-jwt" let client = ApiClient(session: URLProtocolStub.makeSession(), tokenStore: store) let request = ApiRequest(path: "/guest", requiresAuth: true, customBearerToken: "guest-token") #expect(client.buildHeaders(for: request)["Authorization"] == "Bearer guest-token") } @Test("buildHeaders omits Authorization when the request doesn't require auth and has no custom token") func buildHeadersOmitsAuthWhenNotRequired() { let client = makeClient() let request = ApiRequest(path: "/public", requiresAuth: false) #expect(client.buildHeaders(for: request)["Authorization"] == nil) } @Test("buildHeaders adds the module-specific Atomenta-Token when the module has one") func buildHeadersAddsModuleToken() { let client = makeClient() let request = ApiRequest(path: "/stores", module: .store) #expect(client.buildHeaders(for: request)["Atomenta-Token"] == ApiConfig.storeToken) } // MARK: - isSessionExpiredPayload @Test("isSessionExpiredPayload recognizes auth/token/unauthorized error codes") func isSessionExpiredPayloadRecognizesCodes() { let client = makeClient() #expect(client.isSessionExpiredPayload(code: "AUTH_INVALID", message: nil)) #expect(client.isSessionExpiredPayload(code: "token_expired", message: nil)) #expect(client.isSessionExpiredPayload(code: "UNAUTHORIZED", message: nil)) #expect(client.isSessionExpiredPayload(code: "NOT_FOUND", message: nil) == false) } @Test("isSessionExpiredPayload recognizes a message mentioning both token and expired/invalid/session") func isSessionExpiredPayloadRecognizesMessages() { let client = makeClient() #expect(client.isSessionExpiredPayload(code: nil, message: "Token expired, please log in again")) #expect(client.isSessionExpiredPayload(code: nil, message: "Invalid token")) #expect(client.isSessionExpiredPayload(code: nil, message: "Token session invalid")) #expect(client.isSessionExpiredPayload(code: nil, message: "Something else went wrong") == false) #expect(client.isSessionExpiredPayload(code: nil, message: "Token is fine, no issues here") == false) } // MARK: - sanitizedMessage @Test("sanitizedMessage replaces a data:image payload with a generic message") func sanitizedMessageReplacesDataImage() { let client = makeClient() #expect(client.sanitizedMessage("data:image/png;base64,abc123") == "Erro ao processar imagem.") } @Test("sanitizedMessage replaces any base64 payload with a generic message") func sanitizedMessageReplacesBase64Payload() { let client = makeClient() #expect(client.sanitizedMessage("field: base64,abc123def") == "Resposta do servidor inválida.") } @Test("sanitizedMessage truncates long strings to 300 characters with an ellipsis") func sanitizedMessageTruncatesLongStrings() { let client = makeClient() let long = String(repeating: "x", count: 500) let sanitized = client.sanitizedMessage(long) #expect(sanitized?.count == 301) // 300 chars + the ellipsis character #expect(sanitized?.hasSuffix("…") == true) } @Test("sanitizedMessage returns nil for an empty or whitespace-only string") func sanitizedMessageNilForEmptyString() { let client = makeClient() #expect(client.sanitizedMessage(" ") == nil) } // MARK: - serverPayload(from: Data) @Test("serverPayload extracts code and message from an ApiEnvelope-shaped error response") func serverPayloadExtractsFromEnvelope() { let client = makeClient() let data = Data(#"{"error":true,"code":"NOT_FOUND","message":"Store not found"}"#.utf8) let payload = client.serverPayload(from: data) #expect(payload?.code == "NOT_FOUND") #expect(payload?.message == "Store not found") } @Test("serverPayload falls back to a loose JSON object when it isn't a full envelope") func serverPayloadFallsBackToLooseObject() { let client = makeClient() let data = Data(#"{"msg":"Something failed"}"#.utf8) #expect(client.serverPayload(from: data)?.message == "Something failed") } @Test("serverPayload returns nil for a response with no error signal at all") func serverPayloadNilForNoErrorSignal() { let client = makeClient() let data = Data(#"{"id":"1","name":"OK"}"#.utf8) #expect(client.serverPayload(from: data) == nil) }