migration

This commit is contained in:
Daniel Arantes Loverde
2026-08-11 13:22:02 -03:00
parent c4d6998595
commit 7702836fe7
231 changed files with 4329 additions and 1958 deletions

View File

@@ -0,0 +1,42 @@
import Foundation
import Testing
@testable import PediFoods
private func makeCard(nickname: String? = nil, brand: String? = "visa") -> SavedCard {
let json = """
{"id":"1","nickname":\(nickname.map { "\"\($0)\"" } ?? "null"),"holderName":"Jane Doe",
"last4":"4242","brand":\(brand.map { "\"\($0)\"" } ?? "null"),
"expiryMonth":"09","expiryYear":"2027","isDefault":false}
"""
return try! JSONDecoder().decode(SavedCard.self, from: Data(json.utf8))
}
@Test("SavedCard.displayLabel uses the nickname when present")
func savedCardDisplayLabelUsesNickname() {
let card = makeCard(nickname: "Meu cartão")
#expect(card.displayLabel == "Meu cartão")
}
@Test("SavedCard.displayLabel falls back to capitalized brand + last4 when nickname is absent")
func savedCardDisplayLabelFallsBackToBrand() {
let card = makeCard(nickname: nil, brand: "visa")
#expect(card.displayLabel == "Visa •••• 4242")
}
@Test("SavedCard.displayLabel falls back to a generic label when both nickname and brand are absent")
func savedCardDisplayLabelFallsBackToGeneric() {
let card = makeCard(nickname: nil, brand: nil)
#expect(card.displayLabel == "Cartão •••• 4242")
}
@Test("SavedCard.displayLabel ignores an empty-string nickname")
func savedCardDisplayLabelIgnoresEmptyNickname() {
let card = makeCard(nickname: "", brand: "mastercard")
#expect(card.displayLabel == "Mastercard •••• 4242")
}
@Test("SavedCard.expiryLabel joins month and year with a slash")
func savedCardExpiryLabel() {
let card = makeCard()
#expect(card.expiryLabel == "09/2027")
}

View File

@@ -0,0 +1,182 @@
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)
}

View File

@@ -0,0 +1,77 @@
import Foundation
import Testing
@testable import PediFoods
private func encodedKeys<T: Encodable>(_ value: T) throws -> Set<String> {
let data = try JSONEncoder().encode(value)
let object = try JSONSerialization.jsonObject(with: data) as? [String: Any]
return Set((object ?? [:]).keys)
}
@Test("CustomerIdentityUpdatePayload omits profilePicture when it's an empty string")
func customerIdentityUpdatePayloadOmitsEmptyProfilePicture() throws {
let payload = CustomerIdentityUpdatePayload(name: "Jane", email: nil, phoneNumber: nil, profilePicture: "")
let keys = try encodedKeys(payload)
#expect(keys.contains("profilePicture") == false)
#expect(keys.contains("name"))
}
@Test("CustomerIdentityUpdatePayload includes profilePicture when it's non-empty")
func customerIdentityUpdatePayloadIncludesNonEmptyProfilePicture() throws {
let payload = CustomerIdentityUpdatePayload(name: nil, email: nil, phoneNumber: nil, profilePicture: "https://cdn/pic.png")
let keys = try encodedKeys(payload)
#expect(keys.contains("profilePicture"))
}
@Test("CustomerIdentityUpdatePayload omits fields that are nil")
func customerIdentityUpdatePayloadOmitsNilFields() throws {
let payload = CustomerIdentityUpdatePayload(name: "Jane", email: nil, phoneNumber: nil, profilePicture: nil)
let keys = try encodedKeys(payload)
#expect(keys == ["name"])
}
@Test("CustomerAddressPayload maps every field from a CustomerAddress")
func customerAddressPayloadMapsFromCustomerAddress() {
let address = CustomerAddress(
id: "addr-1", label: "Casa", address: "Rua A", number: "100",
complement: "Apto 2", neighborhood: "Centro", city: "SP",
state: "SP", zipCode: "01000-000", latLong: [-23.5, -46.6], isDefault: true
)
let payload = CustomerAddressPayload(from: address)
#expect(payload.label == "Casa")
#expect(payload.address == "Rua A")
#expect(payload.number == "100")
#expect(payload.complement == "Apto 2")
#expect(payload.neighborhood == "Centro")
#expect(payload.city == "SP")
#expect(payload.state == "SP")
#expect(payload.zipCode == "01000-000")
#expect(payload.latLong == [-23.5, -46.6])
}
@Test("CustomerAddressPayload encodes latLong under the lat_long snake_case key")
func customerAddressPayloadEncodesLatLongSnakeCase() throws {
let address = CustomerAddress(
id: nil, label: nil, address: nil, number: nil, complement: nil,
neighborhood: nil, city: nil, state: nil, zipCode: nil,
latLong: [1.0, 2.0], isDefault: nil
)
let keys = try encodedKeys(CustomerAddressPayload(from: address))
#expect(keys.contains("lat_long"))
#expect(keys.contains("latLong") == false)
}
@Test("CustomerAttributesUpdatePayload omits attributes when nil")
func customerAttributesUpdatePayloadOmitsNilAttributes() throws {
let payload = CustomerAttributesUpdatePayload(appVersion: "1.0.0", attributes: nil)
let keys = try encodedKeys(payload)
#expect(keys == ["appVersion"])
}
@Test("CustomerFavoritesMutationResult decodes favorites and an optional store")
func customerFavoritesMutationResultDecodes() throws {
let json = #"{"favorites":["s1","s2"],"store":{"id":"s1","name":"Loja A"}}"#
let result = try JSONDecoder().decode(CustomerFavoritesMutationResult.self, from: Data(json.utf8))
#expect(result.favorites == ["s1", "s2"])
#expect(result.store?.name == "Loja A")
}

View File

@@ -0,0 +1,161 @@
import Foundation
import Testing
@testable import PediFoods
private func decode<T: Decodable>(_ type: T.Type, _ json: String) throws -> T {
try JSONDecoder().decode(T.self, from: Data(json.utf8))
}
@Test("StoreSummary falls back to placeholder id/name when missing")
func storeSummaryDefaultsWhenFieldsMissing() throws {
let store = try decode(StoreSummary.self, "{}")
#expect(store.name == "Loja")
#expect(store.id.isEmpty == false)
#expect(store.rating == nil)
}
@Test("StoreSummary reads reviewsCount from any of its historical key names")
func storeSummaryReviewsCountKeyFallback() throws {
let viaReviews = try decode(StoreSummary.self, #"{"id":"1","name":"A","reviews":7}"#)
#expect(viaReviews.reviewsCount == 7)
let viaTotalReviews = try decode(StoreSummary.self, #"{"id":"1","name":"A","totalReviews":9}"#)
#expect(viaTotalReviews.reviewsCount == 9)
let viaReviewsCount = try decode(StoreSummary.self, #"{"id":"1","name":"A","reviewsCount":3}"#)
#expect(viaReviewsCount.reviewsCount == 3)
}
@Test("StoreSummary coerces rating/deliveryFee/distance from string or int payloads")
func storeSummaryFlexibleNumberCoercion() throws {
let asString = try decode(StoreSummary.self, #"{"id":"1","name":"A","rating":"4,5","deliveryFee":"7,90","distance":"1.2"}"#)
#expect(asString.rating == 4.5)
#expect(asString.deliveryFee == 7.90)
#expect(asString.distance == 1.2)
let asInt = try decode(StoreSummary.self, #"{"id":"1","name":"A","rating":5,"deliveryFee":0}"#)
#expect(asInt.rating == 5.0)
#expect(asInt.deliveryFee == 0.0)
}
@Test("StoreAddressInfo accepts either zipCode or lowercase zipcode key")
func storeAddressInfoZipCodeKeyFallback() throws {
let viaZipCode = try decode(StoreAddressInfo.self, #"{"zipCode":"01000-000"}"#)
#expect(viaZipCode.zipCode == "01000-000")
let viaZipcode = try decode(StoreAddressInfo.self, #"{"zipcode":"02000-000"}"#)
#expect(viaZipcode.zipCode == "02000-000")
}
@Test("StorePaymentMethodsInfo computed hasAny* flags are false when nothing accepted")
func paymentMethodsHasAnyFlagsDefaultFalse() throws {
let none = try decode(StorePaymentMethodsInfo.self, "{}")
#expect(none.hasAnyCreditCard == false)
#expect(none.hasAnyDebitCard == false)
#expect(none.hasAnyVoucher == false)
}
@Test("StorePaymentMethodsInfo computed hasAny* flags are true if any one brand is accepted")
func paymentMethodsHasAnyFlagsTrueOnSingleBrand() throws {
let visaOnly = try decode(StorePaymentMethodsInfo.self, #"{"acceptCreditVisa":true}"#)
#expect(visaOnly.hasAnyCreditCard == true)
#expect(visaOnly.hasAnyDebitCard == false)
let voucherOnly = try decode(StorePaymentMethodsInfo.self, #"{"acceptVoucherSodexo":true}"#)
#expect(voucherOnly.hasAnyVoucher == true)
}
@Test("StoreCatalogProduct decodes pizzaPrices as Double, Int, or comma-decimal String")
func storeCatalogProductPizzaPricesMultiType() throws {
let asDouble = try decode(StoreCatalogProduct.self, #"{"id":"1","name":"P","pizzaPrices":{"M":29.9}}"#)
#expect(asDouble.pizzaPrices["M"] == 29.9)
let asInt = try decode(StoreCatalogProduct.self, #"{"id":"1","name":"P","pizzaPrices":{"M":30}}"#)
#expect(asInt.pizzaPrices["M"] == 30.0)
let asString = try decode(StoreCatalogProduct.self, #"{"id":"1","name":"P","pizzaPrices":{"M":"29,90"}}"#)
#expect(asString.pizzaPrices["M"] == 29.90)
let malformedString = try decode(StoreCatalogProduct.self, #"{"id":"1","name":"P","pizzaPrices":{"M":"not-a-number"}}"#)
#expect(malformedString.pizzaPrices.isEmpty)
}
@Test("StoreCatalogProduct falls back through image/cover/photo and description/desc key names")
func storeCatalogProductImageAndDescriptionFallback() throws {
let viaCover = try decode(StoreCatalogProduct.self, #"{"id":"1","name":"P","cover":"cover.png"}"#)
#expect(viaCover.image == "cover.png")
let viaPhoto = try decode(StoreCatalogProduct.self, #"{"id":"1","name":"P","photo":"photo.png"}"#)
#expect(viaPhoto.image == "photo.png")
let viaDesc = try decode(StoreCatalogProduct.self, #"{"id":"1","name":"P","desc":"short desc"}"#)
#expect(viaDesc.description == "short desc")
}
@Test("StoreCatalogProduct falls back from addonGroups to legacy addons key")
func storeCatalogProductAddonGroupsFallback() throws {
let json = #"{"id":"1","name":"P","addons":[{"id":"g1","name":"Extras","items":[{"id":"i1","name":"Cheese","price":2.5}]}]}"#
let product = try decode(StoreCatalogProduct.self, json)
#expect(product.addonGroups.count == 1)
#expect(product.addonGroups[0].items[0].price == 2.5)
}
@Test("StoreAddonGroup and StoreAddonItem fall back to placeholder id/name when missing")
func addonGroupAndItemDefaults() throws {
let group = try decode(StoreAddonGroup.self, "{}")
#expect(group.name == "Adicionais")
#expect(group.items.isEmpty)
let item = try decode(StoreAddonItem.self, "{}")
#expect(item.name == "Item")
#expect(item.price == nil)
}
@Test("CepLookupResult prefers direct keys over normalized over raw")
func cepLookupResultPrefersDirectOverNormalizedOverRaw() throws {
let json = """
{
"zipCode": "01000-000",
"normalized": { "cep": "02000-000", "logradouro": "Normalized St" },
"raw": { "cep": "03000-000", "address": "Raw St" }
}
"""
let result = try decode(CepLookupResult.self, json)
#expect(result.zipCode == "01000-000")
#expect(result.street == "Normalized St")
}
@Test("CepLookupResult falls back to raw block when direct and normalized are absent")
func cepLookupResultFallsBackToRawBlock() throws {
let json = #"{"raw":{"cep":"03000-000","address":"Raw St","lat":"-23,55","lng":-46.6}}"#
let result = try decode(CepLookupResult.self, json)
#expect(result.zipCode == "03000-000")
#expect(result.street == "Raw St")
#expect(result.latitude == -23.55)
#expect(result.longitude == -46.6)
}
@Test("CepLookupResult resolves via alternate Portuguese field names (bairro/cidade/uf)")
func cepLookupResultPortugueseFieldNames() throws {
let json = #"{"bairro":"Centro","cidade":"São Paulo","uf":"SP"}"#
let result = try decode(CepLookupResult.self, json)
#expect(result.neighborhood == "Centro")
#expect(result.city == "São Paulo")
#expect(result.state == "SP")
}
@Test("StoreCatalogCategory falls back to empty products array and placeholder id/name")
func storeCatalogCategoryDefaults() throws {
let category = try decode(StoreCatalogCategory.self, "{}")
#expect(category.name == "Categoria")
#expect(category.isPizzaCategory == false)
#expect(category.products.isEmpty)
}
@Test("CustomerProfile decodes addressBook from the snake_case address_book key")
func customerProfileAddressBookSnakeCaseKey() throws {
let json = #"{"id":"1","name":"A","email":"a@a.com","address_book":[{"id":"addr1","city":"SP"}]}"#
let profile = try decode(CustomerProfile.self, json)
#expect(profile.addressBook?.count == 1)
#expect(profile.addressBook?.first?.city == "SP")
}

View File

@@ -0,0 +1,73 @@
import Foundation
import Testing
@testable import PediFoods
private func decode<T: Decodable>(_ type: T.Type, _ json: String) throws -> T {
try JSONDecoder().decode(T.self, from: Data(json.utf8))
}
@Test("CreateOrderResult decodes paymentPayload when it's a nested object")
func createOrderResultPaymentPayloadAsObject() throws {
let json = #"{"id":"1","paymentPayload":{"copyPaste":"00020126","qrCodeImage":"base64img","expirationDate":"2026-01-01"}}"#
let result = try decode(CreateOrderResult.self, json)
#expect(result.paymentPayload?.copyPaste == "00020126")
#expect(result.paymentPayload?.qrCodeImage == "base64img")
}
@Test("CreateOrderResult wraps a bare-string paymentPayload into copyPaste")
func createOrderResultPaymentPayloadAsBareString() throws {
let json = #"{"id":"1","paymentPayload":"00020126"}"#
let result = try decode(CreateOrderResult.self, json)
#expect(result.paymentPayload?.copyPaste == "00020126")
#expect(result.paymentPayload?.qrCodeImage == nil)
}
@Test("CreateOrderResult leaves paymentPayload nil when the field is absent")
func createOrderResultPaymentPayloadAbsent() throws {
let result = try decode(CreateOrderResult.self, #"{"id":"1"}"#)
#expect(result.paymentPayload == nil)
}
@Test("CreateOrderPaymentPayload falls back from copyPaste to the legacy payload key")
func createOrderPaymentPayloadCopyPasteKeyFallback() throws {
let payload = try decode(CreateOrderPaymentPayload.self, #"{"payload":"00020126"}"#)
#expect(payload.copyPaste == "00020126")
}
@Test("CreateOrderPaymentPayload falls back from qrCodeImage to the legacy encodedImage key")
func createOrderPaymentPayloadQrCodeImageKeyFallback() throws {
let payload = try decode(CreateOrderPaymentPayload.self, #"{"encodedImage":"base64img"}"#)
#expect(payload.qrCodeImage == "base64img")
}
@Test("ValidateDeliveryAddressResult reads camelCase or snake_case keys interchangeably")
func validateDeliveryAddressResultKeyFallback() throws {
let camelCase = try decode(ValidateDeliveryAddressResult.self, #"{"deliveryAllowed":true,"reasonCode":"OUT_OF_RANGE"}"#)
#expect(camelCase.deliveryAllowed == true)
#expect(camelCase.reasonCode == "OUT_OF_RANGE")
let snakeCase = try decode(ValidateDeliveryAddressResult.self, #"{"delivery_allowed":false,"reason_code":"OUT_OF_RANGE"}"#)
#expect(snakeCase.deliveryAllowed == false)
#expect(snakeCase.reasonCode == "OUT_OF_RANGE")
}
@Test("ValidateDeliveryAddressResult resolves deliveryFee from any of its three key names")
func validateDeliveryAddressResultDeliveryFeeThreeWayFallback() throws {
let viaDeliveryFee = try decode(ValidateDeliveryAddressResult.self, #"{"deliveryFee":5.5}"#)
#expect(viaDeliveryFee.deliveryFee == 5.5)
let viaFee = try decode(ValidateDeliveryAddressResult.self, #"{"fee":6.5}"#)
#expect(viaFee.deliveryFee == 6.5)
let viaTaxa = try decode(ValidateDeliveryAddressResult.self, #"{"taxa":"7,50"}"#)
#expect(viaTaxa.deliveryFee == 7.5)
}
@Test("ValidateDeliveryAddressResult defaults every field to nil on an empty payload")
func validateDeliveryAddressResultEmptyPayload() throws {
let result = try decode(ValidateDeliveryAddressResult.self, "{}")
#expect(result.deliveryAllowed == nil)
#expect(result.reasonCode == nil)
#expect(result.deliveryFee == nil)
#expect(result.sameCity == nil)
}

View File

@@ -0,0 +1,151 @@
import Foundation
import Testing
@testable import PediFoods
private func decode<T: Decodable>(_ type: T.Type, _ json: String) throws -> T {
try JSONDecoder().decode(T.self, from: Data(json.utf8))
}
private func makeOrder(
status: String? = nil,
paymentStatus: String? = nil,
paymentConfirmed: Bool? = nil,
customerOtp: String? = nil,
otp: String? = nil,
confirmOtp: String? = nil,
timeline: [PublicOrderTimelineEvent] = []
) -> PublicOrderResult {
PublicOrderResult(
id: "order-1",
status: status,
paymentStatus: paymentStatus,
paymentConfirmed: paymentConfirmed,
otp: otp,
customerOtp: customerOtp,
confirmOtp: confirmOtp,
timeline: timeline
)
}
// MARK: - displayOtpCode
@Test("PublicOrderResult.displayOtpCode prefers customerOtp, then otp, then confirmOtp")
func displayOtpCodePriorityOrder() {
let allThree = makeOrder(customerOtp: "1111", otp: "2222", confirmOtp: "3333")
#expect(allThree.displayOtpCode == "1111")
let otpAndConfirm = makeOrder(otp: "2222", confirmOtp: "3333")
#expect(otpAndConfirm.displayOtpCode == "2222")
let confirmOnly = makeOrder(confirmOtp: "3333")
#expect(confirmOnly.displayOtpCode == "3333")
}
@Test("PublicOrderResult.displayOtpCode skips blank/whitespace-only values")
func displayOtpCodeSkipsBlankValues() {
let order = makeOrder(customerOtp: " ", otp: "2222")
#expect(order.displayOtpCode == "2222")
}
@Test("PublicOrderResult.displayOtpCode is nil when no OTP field is set")
func displayOtpCodeNilWhenAbsent() {
#expect(makeOrder().displayOtpCode == nil)
}
// MARK: - isInDeliveryRoute / isFinalStatus
@Test("PublicOrderResult.isInDeliveryRoute recognizes OUT_FOR_DELIVERY and Portuguese equivalents")
func isInDeliveryRouteRecognizesKnownStatuses() {
#expect(makeOrder(status: "OUT_FOR_DELIVERY").isInDeliveryRoute)
#expect(makeOrder(status: "em_rota").isInDeliveryRoute)
#expect(makeOrder(status: "ON_ROUTE").isInDeliveryRoute)
#expect(makeOrder(status: "PREPARING").isInDeliveryRoute == false)
}
@Test("PublicOrderResult.isFinalStatus matches COMPLETED, CANCELED, and REFUNDED exactly")
func isFinalStatusMatchesTerminalStatuses() {
#expect(makeOrder(status: "COMPLETED").isFinalStatus)
#expect(makeOrder(status: "CANCELED").isFinalStatus)
#expect(makeOrder(status: "REFUNDED").isFinalStatus)
#expect(makeOrder(status: "PREPARING").isFinalStatus == false)
}
// MARK: - isPaymentConfirmed
@Test("PublicOrderResult.isPaymentConfirmed trusts the explicit paymentConfirmed flag first")
func isPaymentConfirmedTrustsExplicitFlag() {
#expect(makeOrder(paymentStatus: "PENDING", paymentConfirmed: true).isPaymentConfirmed)
#expect(makeOrder(paymentStatus: "PAID", paymentConfirmed: false).isPaymentConfirmed == false)
}
@Test("PublicOrderResult.isPaymentConfirmed infers from a paymentStatus that looks confirmed")
func isPaymentConfirmedInfersFromPaymentStatus() {
#expect(makeOrder(paymentStatus: "CONFIRMED").isPaymentConfirmed)
#expect(makeOrder(paymentStatus: "approved").isPaymentConfirmed)
#expect(makeOrder(paymentStatus: "PENDING").isPaymentConfirmed == false)
#expect(makeOrder(paymentStatus: "PAYMENT_FAILED").isPaymentConfirmed == false)
}
@Test("PublicOrderResult.isPaymentConfirmed falls back to the timeline when status fields don't confirm")
func isPaymentConfirmedFallsBackToTimeline() {
let confirmingEvent = PublicOrderTimelineEvent(status: "PAYMENT_APPROVED", message: nil, time: nil)
let order = makeOrder(status: "PROCESSING", timeline: [confirmingEvent])
#expect(order.isPaymentConfirmed)
}
@Test("PublicOrderResult.isPaymentConfirmed is false with no confirming signal anywhere")
func isPaymentConfirmedFalseWithNoSignal() {
#expect(makeOrder(status: "PROCESSING").isPaymentConfirmed == false)
}
// MARK: - CreateOrderResult -> PublicOrderResult mapping
@Test("CreateOrderResult.asPublicOrderResult maps every carried-over field")
func createOrderResultMapsToPublicOrderResult() throws {
let created = try decode(CreateOrderResult.self, #"{"id":"o1","shortId":"S1","status":"PENDING","paymentMethod":"PIX"}"#)
let mapped = created.asPublicOrderResult()
#expect(mapped.id == "o1")
#expect(mapped.shortId == "S1")
#expect(mapped.status == "PENDING")
#expect(mapped.paymentMethod == "PIX")
}
// MARK: - Nested decode fallbacks
@Test("PublicOrderDeliveryAddress falls back through street/address and zip/zipCode/zipcode key names")
func publicOrderDeliveryAddressKeyFallback() throws {
let viaAddress = try decode(PublicOrderDeliveryAddress.self, #"{"address":"Rua A","zipcode":"01000-000"}"#)
#expect(viaAddress.street == "Rua A")
#expect(viaAddress.zip == "01000-000")
}
@Test("PublicOrderItem falls back from qty to the legacy quantity key")
func publicOrderItemQuantityKeyFallback() throws {
let item = try decode(PublicOrderItem.self, #"{"quantity":3,"price":9.9}"#)
#expect(item.qty == 3)
}
@Test("PublicOrderTimelineEvent falls back from message to event, and from time to createdAt/updatedAt")
func publicOrderTimelineEventKeyFallback() throws {
let viaEvent = try decode(PublicOrderTimelineEvent.self, #"{"status":"S","event":"Order placed"}"#)
#expect(viaEvent.message == "Order placed")
let viaCreatedAt = try decode(PublicOrderTimelineEvent.self, #"{"status":"S","createdAt":"2026-01-01T00:00:00Z"}"#)
#expect(viaCreatedAt.time == "2026-01-01T00:00:00Z")
}
@Test("AppOrderSummary falls back through orderId/realId/id for its own id, and through many keys for storeLogoURL")
func appOrderSummaryIdAndLogoFallback() throws {
let viaRealId = try decode(AppOrderSummary.self, #"{"realId":"real-1"}"#)
#expect(viaRealId.id == "real-1")
let viaLogo = try decode(AppOrderSummary.self, #"{"orderId":"o1","store_logo":"logo.png"}"#)
#expect(viaLogo.storeLogoURL == "logo.png")
}
@Test("AppOrderSummary falls back to the shared date key when createdAt/updatedAt are absent")
func appOrderSummaryDateFallback() throws {
let summary = try decode(AppOrderSummary.self, #"{"orderId":"o1","date":"2026-01-01"}"#)
#expect(summary.createdAt == "2026-01-01")
#expect(summary.updatedAt == "2026-01-01")
}

View File

@@ -0,0 +1,37 @@
import Foundation
import Testing
@testable import PediFoods
private func decode<T: Decodable>(_ type: T.Type, _ json: String) throws -> T {
try JSONDecoder().decode(T.self, from: Data(json.utf8))
}
@Test("StorePizzaConfig defaults every list to empty when fields are missing")
func storePizzaConfigDefaultsToEmptyLists() throws {
let config = try decode(StorePizzaConfig.self, "{}")
#expect(config.sizes.isEmpty)
#expect(config.doughs.isEmpty)
#expect(config.crusts.isEmpty)
}
@Test("StorePizzaCrust falls back to a placeholder id when missing")
func storePizzaCrustPlaceholderId() throws {
let crust = try decode(StorePizzaCrust.self, "{}")
#expect(crust.id.isEmpty == false)
#expect(crust.priceModifier == nil)
}
@Test("StorePizzaCrust coerces priceModifier from Double, Int, or comma-decimal String")
func storePizzaCrustPriceModifierCoercion() throws {
let asDouble = try decode(StorePizzaCrust.self, #"{"id":"1","priceModifier":3.5}"#)
#expect(asDouble.priceModifier == 3.5)
let asInt = try decode(StorePizzaCrust.self, #"{"id":"1","priceModifier":4}"#)
#expect(asInt.priceModifier == 4.0)
let asString = try decode(StorePizzaCrust.self, #"{"id":"1","priceModifier":"3,50"}"#)
#expect(asString.priceModifier == 3.5)
let malformed = try decode(StorePizzaCrust.self, #"{"id":"1","priceModifier":"not-a-number"}"#)
#expect(malformed.priceModifier == nil)
}

View File

@@ -0,0 +1,102 @@
import Foundation
import Testing
@testable import PediFoods
private func decode<T: Decodable>(_ type: T.Type, _ json: String) throws -> T {
try JSONDecoder().decode(T.self, from: Data(json.utf8))
}
@Test("SubmitOrderReviewResult accepts orderPositiveTags as either an array or a single string")
func submitOrderReviewResultTagsArrayOrSingleString() throws {
let asArray = try decode(SubmitOrderReviewResult.self, #"{"orderPositiveTags":["fast","tasty"]}"#)
#expect(asArray.orderPositiveTags == ["fast", "tasty"])
let asSingleString = try decode(SubmitOrderReviewResult.self, #"{"orderPositiveTags":"fast"}"#)
#expect(asSingleString.orderPositiveTags == ["fast"])
}
@Test("SubmitOrderReviewResult falls back from orderPositiveTags to the legacy itemFeedback key")
func submitOrderReviewResultTagsLegacyKeyFallback() throws {
let result = try decode(SubmitOrderReviewResult.self, #"{"itemFeedback":["fresh"]}"#)
#expect(result.orderPositiveTags == ["fresh"])
}
@Test("SubmitOrderReviewResult coerces appNps from Int, Double, or comma-decimal String")
func submitOrderReviewResultAppNpsCoercion() throws {
let asInt = try decode(SubmitOrderReviewResult.self, #"{"appNps":9}"#)
#expect(asInt.appNps == 9)
let asDouble = try decode(SubmitOrderReviewResult.self, #"{"appNps":8.6}"#)
#expect(asDouble.appNps == 9)
let asString = try decode(SubmitOrderReviewResult.self, #"{"appNps":"7,4"}"#)
#expect(asString.appNps == 7)
}
@Test("SubmitOrderReviewResult reads appNps from the legacy app_nps key")
func submitOrderReviewResultAppNpsLegacyKey() throws {
let result = try decode(SubmitOrderReviewResult.self, #"{"app_nps":10}"#)
#expect(result.appNps == 10)
}
@Test("SubmitOrderReviewResult prefers the flat storeReplyMessage field over nested reply objects")
func submitOrderReviewResultStoreReplyMessagePrefersFlatField() throws {
let json = #"{"storeReplyMessage":"Obrigado!","storeReply":{"message":"ignored"}}"#
let result = try decode(SubmitOrderReviewResult.self, json)
#expect(result.storeReplyMessage == "Obrigado!")
}
@Test("SubmitOrderReviewResult extracts a reply message from a nested object's candidate keys")
func submitOrderReviewResultStoreReplyMessageFromNestedObject() throws {
let viaMessage = try decode(SubmitOrderReviewResult.self, #"{"storeReply":{"message":"Obrigado!"}}"#)
#expect(viaMessage.storeReplyMessage == "Obrigado!")
let viaText = try decode(SubmitOrderReviewResult.self, #"{"reply":{"text":"Valeu!"}}"#)
#expect(viaText.storeReplyMessage == "Valeu!")
let viaStoreResponse = try decode(SubmitOrderReviewResult.self, #"{"store_response":{"content":"Obrigado pela visita!"}}"#)
#expect(viaStoreResponse.storeReplyMessage == "Obrigado pela visita!")
}
@Test("SubmitOrderReviewResult extracts a reply date from a nested object's candidate keys")
func submitOrderReviewResultStoreReplyDateFromNestedObject() throws {
let result = try decode(SubmitOrderReviewResult.self, #"{"storeReply":{"repliedAt":"2026-01-05"}}"#)
#expect(result.storeReplyAt == "2026-01-05")
}
@Test("SubmitOrderReviewResult leaves storeReplyMessage nil when there's no reply at all")
func submitOrderReviewResultNoReply() throws {
let result = try decode(SubmitOrderReviewResult.self, "{}")
#expect(result.storeReplyMessage == nil)
#expect(result.storeReplyAt == nil)
}
@Test("PublicStoreReviewsResult decodes a bare top-level array of reviews")
func publicStoreReviewsResultBareArray() throws {
let json = #"[{"id":"1"},{"id":"2"}]"#
let result = try decode(PublicStoreReviewsResult.self, json)
#expect(result.reviews.count == 2)
}
@Test("PublicStoreReviewsResult falls back through reviews, data, and items wrapper keys")
func publicStoreReviewsResultWrapperKeyFallback() throws {
let viaReviews = try decode(PublicStoreReviewsResult.self, #"{"reviews":[{"id":"1"}]}"#)
#expect(viaReviews.reviews.count == 1)
let viaData = try decode(PublicStoreReviewsResult.self, #"{"data":[{"id":"1"},{"id":"2"}]}"#)
#expect(viaData.reviews.count == 2)
let viaItems = try decode(PublicStoreReviewsResult.self, #"{"items":[{"id":"1"}]}"#)
#expect(viaItems.reviews.count == 1)
}
@Test("PublicStoreReviewsResult defaults to an empty array when nothing matches")
func publicStoreReviewsResultDefaultsToEmpty() throws {
let result = try decode(PublicStoreReviewsResult.self, "{}")
#expect(result.reviews.isEmpty)
}
@Test("ReviewPlatform.current is iOS on this platform")
func reviewPlatformCurrentIsIOS() {
#expect(ReviewPlatform.current == .ios)
}

View File

@@ -0,0 +1,50 @@
import Foundation
import Testing
@testable import PediFoods
/// `AppContentCache.shared` is a true singleton with no injectable
/// instance; `invalidate()` with no prefix wipes every key regardless of
/// which test wrote it. Serialized so tests can't stomp on each other.
@Suite(.serialized)
struct AppContentCacheTests {
@Test("value(for:) returns what was just set")
func setThenGetRoundTrips() {
let key = "test.\(UUID().uuidString)"
AppContentCache.shared.set("hello", for: key, ttl: 60)
#expect(AppContentCache.shared.value(for: key, as: String.self) == "hello")
}
@Test("value(for:) returns nil once the TTL has elapsed")
func expiresAfterTTL() {
let key = "test.\(UUID().uuidString)"
AppContentCache.shared.set("hello", for: key, ttl: -1) // already expired
#expect(AppContentCache.shared.value(for: key, as: String.self) == nil)
}
@Test("value(for:) returns nil for a key that was never set")
func missingKeyReturnsNil() {
let key = "test.\(UUID().uuidString)"
#expect(AppContentCache.shared.value(for: key, as: String.self) == nil)
}
@Test("invalidate(prefix:) only removes keys matching that prefix")
func invalidateWithPrefixIsScoped() {
let prefix = "test.\(UUID().uuidString)."
AppContentCache.shared.set("a", for: "\(prefix)a", ttl: 60)
AppContentCache.shared.set("b", for: "\(prefix)b", ttl: 60)
AppContentCache.shared.set("unrelated", for: "unrelated.\(UUID().uuidString)", ttl: 60)
AppContentCache.shared.invalidate(prefix: prefix)
#expect(AppContentCache.shared.value(for: "\(prefix)a", as: String.self) == nil)
#expect(AppContentCache.shared.value(for: "\(prefix)b", as: String.self) == nil)
}
@Test("invalidate() with no prefix clears every entry")
func invalidateWithNoPrefixClearsEverything() {
let key = "test.\(UUID().uuidString)"
AppContentCache.shared.set("hello", for: key, ttl: 60)
AppContentCache.shared.invalidate()
#expect(AppContentCache.shared.value(for: key, as: String.self) == nil)
}
}

View File

@@ -0,0 +1,85 @@
import Testing
@testable import PediFoods
// MARK: - formatPhoneBR
@Test("formatPhoneBR progressively formats as digits are typed")
func formatPhoneBRProgressiveFormatting() {
#expect(formatPhoneBR("") == "")
#expect(formatPhoneBR("1") == "(1")
#expect(formatPhoneBR("11") == "(11")
#expect(formatPhoneBR("119") == "(11) 9")
#expect(formatPhoneBR("1199999") == "(11) 99999")
#expect(formatPhoneBR("11999999999") == "(11) 99999-9999")
}
@Test("formatPhoneBR strips non-digit characters before formatting")
func formatPhoneBRStripsNonDigits() {
#expect(formatPhoneBR("(11) 99999-9999") == "(11) 99999-9999")
}
@Test("formatPhoneBR caps input at 11 digits")
func formatPhoneBRCapsAtElevenDigits() {
#expect(formatPhoneBR("119999999999999") == "(11) 99999-9999")
}
// MARK: - normalizePhoneNumberForAPI
@Test("normalizePhoneNumberForAPI returns empty for fewer than 10 digits")
func normalizePhoneNumberReturnsEmptyBelowTenDigits() {
#expect(normalizePhoneNumberForAPI("119999") == "")
}
@Test("normalizePhoneNumberForAPI prepends +55 to a bare local number")
func normalizePhoneNumberPrependsCountryCode() {
#expect(normalizePhoneNumberForAPI("11999999999") == "+5511999999999")
}
@Test("normalizePhoneNumberForAPI doesn't double the country code when it's already present")
func normalizePhoneNumberDoesNotDoubleCountryCode() {
#expect(normalizePhoneNumberForAPI("5511999999999") == "+5511999999999")
}
// MARK: - userFacingAuthErrorMessage
@Test("userFacingAuthErrorMessage uses ApiServiceError.sessionExpired's message when present")
func authErrorMessageUsesSessionExpiredMessage() {
let message = userFacingAuthErrorMessage(ApiServiceError.sessionExpired("Custom message"))
#expect(message == "Custom message")
}
@Test("userFacingAuthErrorMessage falls back to a default for a blank sessionExpired message")
func authErrorMessageFallsBackForBlankSessionExpired() {
let message = userFacingAuthErrorMessage(ApiServiceError.sessionExpired(" "))
#expect(message == "Sua sessão expirou. Faça login novamente.")
}
@Test("userFacingAuthErrorMessage maps NetworkError.httpError codes to distinct Portuguese messages")
func authErrorMessageMapsHttpErrorCodes() {
#expect(userFacingAuthErrorMessage(NetworkError.httpError(400, nil)).contains("Revise as informações"))
#expect(userFacingAuthErrorMessage(NetworkError.httpError(404, nil)).contains("Não encontramos"))
#expect(userFacingAuthErrorMessage(NetworkError.httpError(429, nil)).contains("Muitas tentativas"))
#expect(userFacingAuthErrorMessage(NetworkError.httpError(503, nil)).contains("instáveis"))
#expect(userFacingAuthErrorMessage(NetworkError.httpError(418, nil)).contains("Não foi possível concluir"))
}
@Test("userFacingAuthErrorMessage prefers the server's own message over the generic httpError mapping")
func authErrorMessagePrefersServerMessageOverGenericMapping() {
let message = userFacingAuthErrorMessage(NetworkError.httpError(400, "CPF já cadastrado"))
#expect(message == "CPF já cadastrado")
}
@Test("userFacingAuthErrorMessage maps unauthorized, rateLimited, transportError, cancelled, and timedOut")
func authErrorMessageMapsOtherNetworkErrorCases() {
#expect(userFacingAuthErrorMessage(NetworkError.unauthorized(nil)).contains("acesso expirou"))
#expect(userFacingAuthErrorMessage(NetworkError.rateLimited(nil)).contains("Muitas tentativas"))
#expect(userFacingAuthErrorMessage(NetworkError.transportError("timeout")).contains("conectar ao servidor"))
#expect(userFacingAuthErrorMessage(NetworkError.cancelled) == "Cancelado")
#expect(userFacingAuthErrorMessage(NetworkError.timedOut).contains("demorou demais"))
}
@Test("userFacingAuthErrorMessage falls back to a generic message for an unrecognized error type")
func authErrorMessageGenericFallbackForUnknownErrorType() {
struct SomeOtherError: Error {}
#expect(userFacingAuthErrorMessage(SomeOtherError()) == "Não foi possível concluir a operação. Tente novamente.")
}

View File

@@ -0,0 +1,127 @@
import Foundation
import Testing
@testable import PediFoods
private func makeItem(id: String, quantity: Int, unitPrice: Double, addons: [CartItemAddonState] = []) -> CartItemState {
CartItemState(id: id, productId: "p-\(id)", storeId: "s1", name: "Item \(id)", addons: addons, quantity: quantity, unitPrice: unitPrice)
}
@Test("totalItems sums quantities across every item")
func totalItemsSumsQuantities() {
var cart = CartState()
cart.items = [makeItem(id: "1", quantity: 2, unitPrice: 10), makeItem(id: "2", quantity: 3, unitPrice: 5)]
#expect(cart.totalItems == 5)
}
@Test("recalculateTotal sums quantity times unitPrice across every item")
func recalculateTotalSumsLineItems() {
var cart = CartState()
cart.items = [makeItem(id: "1", quantity: 2, unitPrice: 10), makeItem(id: "2", quantity: 1, unitPrice: 5)]
cart.recalculateTotal()
#expect(cart.total == 25)
}
@Test("toOrderItemsPayload drops zero-quantity addons and nils out empty choices")
func toOrderItemsPayloadDropsZeroQuantityAddonsAndEmptyChoices() {
var cart = CartState()
let item = CartItemState(
id: "1", productId: "p1", storeId: "s1", name: "Pizza", choices: [],
addons: [
CartItemAddonState(id: "a1", name: "Cheese", quantity: 1, unitPrice: 2),
CartItemAddonState(id: "a2", name: "Removed", quantity: 0, unitPrice: 3)
],
quantity: 1, unitPrice: 20
)
cart.items = [item]
let payload = cart.toOrderItemsPayload()
#expect(payload.count == 1)
#expect(payload[0].addons.count == 1)
#expect(payload[0].addons[0].addonId == "a1")
#expect(payload[0].choices == nil)
}
/// `CartState`'s mutating methods persist through `SessionStateStore`,
/// which shares one static `UserDefaults` swap point. `.serialized` only
/// orders tests *within* a suite, not across two separate suite types -
/// so these live as an extension of `SessionStateStorePersistenceTests`
/// (defined in SessionStateStoreTests.swift) rather than their own
/// `@Suite`, putting them in the same serialization domain instead of a
/// second one that could still race the first.
extension SessionStateStorePersistenceTests {
@Test("add appends a new item and recalculates the total")
func addAppendsNewItem() {
withIsolatedDefaults {
var cart = CartState(storeId: "s1", storeName: "Loja A")
cart.add(item: makeItem(id: "1", quantity: 2, unitPrice: 10))
#expect(cart.items.count == 1)
#expect(cart.total == 20)
}
}
@Test("add increments the quantity of an already-present item instead of duplicating it")
func addIncrementsExistingItem() {
withIsolatedDefaults {
var cart = CartState(storeId: "s1", storeName: "Loja A")
cart.add(item: makeItem(id: "1", quantity: 1, unitPrice: 10))
cart.add(item: makeItem(id: "1", quantity: 2, unitPrice: 10))
#expect(cart.items.count == 1)
#expect(cart.items[0].quantity == 3)
#expect(cart.total == 30)
}
}
@Test("set removes the item when its quantity is zero or less")
func setRemovesItemAtZeroQuantity() {
withIsolatedDefaults {
var cart = CartState(storeId: "s1", storeName: "Loja A")
cart.add(item: makeItem(id: "1", quantity: 1, unitPrice: 10))
cart.set(item: makeItem(id: "1", quantity: 0, unitPrice: 10))
#expect(cart.items.isEmpty)
}
}
@Test("set clears storeId/storeName once the cart becomes empty")
func setClearsStoreWhenCartBecomesEmpty() {
withIsolatedDefaults {
var cart = CartState(storeId: "s1", storeName: "Loja A")
cart.add(item: makeItem(id: "1", quantity: 1, unitPrice: 10))
cart.set(item: makeItem(id: "1", quantity: 0, unitPrice: 10))
#expect(cart.storeId == nil)
#expect(cart.storeName == nil)
}
}
@Test("increment increases an existing item's quantity by one")
func incrementIncreasesQuantity() {
withIsolatedDefaults {
var cart = CartState(storeId: "s1", storeName: "Loja A")
cart.add(item: makeItem(id: "1", quantity: 1, unitPrice: 10))
cart.increment(itemId: "1")
#expect(cart.items[0].quantity == 2)
#expect(cart.total == 20)
}
}
@Test("decrement removes the item once its quantity reaches zero")
func decrementRemovesItemAtZero() {
withIsolatedDefaults {
var cart = CartState(storeId: "s1", storeName: "Loja A")
cart.add(item: makeItem(id: "1", quantity: 1, unitPrice: 10))
cart.decrement(itemId: "1")
#expect(cart.items.isEmpty)
#expect(cart.total == 0)
}
}
@Test("clear empties the cart and resets the total")
func clearEmptiesCart() {
withIsolatedDefaults {
var cart = CartState(storeId: "s1", storeName: "Loja A")
cart.add(item: makeItem(id: "1", quantity: 1, unitPrice: 10))
cart.clear()
#expect(cart.items.isEmpty)
#expect(cart.storeId == nil)
#expect(cart.total == 0)
}
}
}

View File

@@ -0,0 +1,32 @@
import Testing
@testable import PediFoods
@Test("CheckoutPaymentMethod raw values match the backend's expected wire format")
func checkoutPaymentMethodRawValues() {
#expect(CheckoutPaymentMethod.pix.rawValue == "PIX")
#expect(CheckoutPaymentMethod.creditCard.rawValue == "CREDIT_CARD")
#expect(CheckoutPaymentMethod.debitCard.rawValue == "DEBIT_CARD")
#expect(CheckoutPaymentMethod.money.rawValue == "MONEY")
#expect(CheckoutPaymentMethod.voucher.rawValue == "VOUCHER")
}
@Test("CheckoutPaymentMethod only pix and creditCard have a subtitle")
func checkoutPaymentMethodSubtitles() {
#expect(CheckoutPaymentMethod.pix.subtitle == "Aprovação imediata")
#expect(CheckoutPaymentMethod.creditCard.subtitle == "No app: rápido e seguro")
#expect(CheckoutPaymentMethod.debitCard.subtitle == nil)
#expect(CheckoutPaymentMethod.money.subtitle == nil)
#expect(CheckoutPaymentMethod.voucher.subtitle == nil)
}
@Test("CheckoutPaymentMethod credit and debit cards share the same icon")
func checkoutPaymentMethodCardIconsShared() {
#expect(CheckoutPaymentMethod.creditCard.iconName == "creditcard.fill")
#expect(CheckoutPaymentMethod.debitCard.iconName == "creditcard.fill")
}
@Test("CheckoutDeliveryType raw values match the backend's expected wire format")
func checkoutDeliveryTypeRawValues() {
#expect(CheckoutDeliveryType.delivery.rawValue == "DELIVERY")
#expect(CheckoutDeliveryType.pickup.rawValue == "PICKUP")
}

View File

@@ -0,0 +1,32 @@
import SwiftUI
import Testing
@testable import PediFoods
@MainActor
private func makeCheckoutView() -> CheckoutView {
var appState = AppState()
let appStateBinding = Binding(get: { appState }, set: { appState = $0 })
var selectedTab = MainTab.cart
let tabBinding = Binding(get: { selectedTab }, set: { selectedTab = $0 })
return CheckoutView(appState: appStateBinding, selectedTab: tabBinding)
}
@Test("formatCurrency formats a Double as Brazilian Real with a comma decimal separator")
@MainActor
func formatCurrencyUsesCommaDecimalSeparator() {
let view = makeCheckoutView()
#expect(view.formatCurrency(29.9) == "R$ 29,90")
#expect(view.formatCurrency(0) == "R$ 0,00")
#expect(view.formatCurrency(1234.5) == "R$ 1234,50")
}
@Test("CheckoutPayloadValidationError provides a distinct Portuguese message per case")
func checkoutPayloadValidationErrorMessages() {
#expect(CheckoutView.CheckoutPayloadValidationError.emptyCart.errorDescription == "Carrinho vazio.")
#expect(CheckoutView.CheckoutPayloadValidationError.missingCustomerName.errorDescription == "Nome do cliente não informado.")
#expect(CheckoutView.CheckoutPayloadValidationError.missingCustomerEmail.errorDescription == "Email do cliente não informado.")
#expect(CheckoutView.CheckoutPayloadValidationError.missingCustomerPhone.errorDescription == "Telefone do cliente não informado.")
#expect(CheckoutView.CheckoutPayloadValidationError.missingAddressStreet.errorDescription == "Rua do endereço não informada.")
#expect(CheckoutView.CheckoutPayloadValidationError.missingAddressNumber.errorDescription == "Número do endereço não informado.")
#expect(CheckoutView.CheckoutPayloadValidationError.missingAddressNeighborhood.errorDescription == "Bairro do endereço não informado.")
}

View File

@@ -0,0 +1,67 @@
import Foundation
import Testing
@testable import PediFoods
@Test("PushDeepLinkParser parses order_status with an explicit orderId")
func parsesOrderStatusWithExplicitOrderId() {
let userInfo: [AnyHashable: Any] = ["type": "order_status", "orderId": "order-1", "shortId": "SHORT1"]
let destination = PushDeepLinkParser.parse(userInfo)
#expect(destination == .orderTracking(orderId: "order-1", shortId: "SHORT1"))
}
@Test("PushDeepLinkParser falls back to shortId as the orderId when orderId is absent")
func fallsBackToShortIdAsOrderId() {
let userInfo: [AnyHashable: Any] = ["type": "order_status", "shortId": "SHORT1"]
let destination = PushDeepLinkParser.parse(userInfo)
#expect(destination == .orderTracking(orderId: "SHORT1", shortId: "SHORT1"))
}
@Test("PushDeepLinkParser returns nil for order_status with neither orderId nor shortId")
func returnsNilForOrderStatusWithNoIdentifiers() {
let userInfo: [AnyHashable: Any] = ["type": "order_status"]
#expect(PushDeepLinkParser.parse(userInfo) == nil)
}
@Test("PushDeepLinkParser parses a targetScreen payload into .screen with its params")
func parsesTargetScreenWithParams() {
let userInfo: [AnyHashable: Any] = [
"targetScreen": "promo",
"couponId": "abc123",
"storeId": "store-9"
]
guard case let .screen(name, params) = PushDeepLinkParser.parse(userInfo) else {
Issue.record("expected .screen destination")
return
}
#expect(name == "promo")
#expect(params == ["couponId": "abc123", "storeId": "store-9"])
}
@Test("PushDeepLinkParser excludes type and targetScreen keys from the screen's params")
func excludesRoutingKeysFromScreenParams() {
let userInfo: [AnyHashable: Any] = ["type": "campaign", "targetScreen": "promo", "couponId": "abc123"]
guard case let .screen(_, params) = PushDeepLinkParser.parse(userInfo) else {
Issue.record("expected .screen destination")
return
}
#expect(params["type"] == nil)
#expect(params["targetScreen"] == nil)
#expect(params["couponId"] == "abc123")
}
@Test("PushDeepLinkParser returns nil for a payload with neither order_status nor targetScreen")
func returnsNilForUnroutablePayload() {
let userInfo: [AnyHashable: Any] = ["type": "campaign", "campaignId": "c1"]
#expect(PushDeepLinkParser.parse(userInfo) == nil)
}
@Test("PushDeepLinkParser drops non-String param values from a screen payload")
func dropsNonStringParamValues() {
let userInfo: [AnyHashable: Any] = ["targetScreen": "promo", "count": 5, "label": "sale"]
guard case let .screen(_, params) = PushDeepLinkParser.parse(userInfo) else {
Issue.record("expected .screen destination")
return
}
#expect(params["count"] == nil)
#expect(params["label"] == "sale")
}

View File

@@ -0,0 +1,35 @@
import Testing
@testable import PediFoods
@Test("orderTracking destination routes to the Profile tab with a pending auto-intent order context")
@MainActor
func routeEffectForOrderTracking() {
let view = ContentView()
let effect = view.routeEffect(for: .orderTracking(orderId: "order-1", shortId: "PF-1"))
#expect(effect == .navigateToOrder(
OrderRouteContext(orderId: "order-1", shortId: "PF-1", paymentMethod: nil, total: nil, intent: .auto),
tab: .profile
))
}
@Test("orderTracking destination carries a nil shortId through unchanged")
@MainActor
func routeEffectForOrderTrackingWithoutShortId() {
let view = ContentView()
let effect = view.routeEffect(for: .orderTracking(orderId: "order-2", shortId: nil))
#expect(effect == .navigateToOrder(
OrderRouteContext(orderId: "order-2", shortId: nil, paymentMethod: nil, total: nil, intent: .auto),
tab: .profile
))
}
@Test("screen destination has no routing effect yet (unhandled deep-link screen)")
@MainActor
func routeEffectForScreenDestinationIsNone() {
let view = ContentView()
let effect = view.routeEffect(for: .screen(name: "promo", params: ["code": "SAVE10"]))
#expect(effect == .none)
}

View File

@@ -0,0 +1,49 @@
import Foundation
import Testing
@testable import PediFoods
@Test("Customer profile decodes persisted favorite store ids")
func customerProfileDecodesFavoriteStoreIds() throws {
let json = """
{
"error": false,
"result": {
"id": "cust_1",
"name": "Daniel",
"email": "daniel@example.com",
"phoneNumber": "+5511999999999",
"favorites": ["store_a", "store_b"],
"address_book": []
}
}
"""
let envelope = try JSONDecoder().decode(ApiEnvelope<CustomerProfile>.self, from: Data(json.utf8))
#expect(envelope.result?.favorites == ["store_a", "store_b"])
}
@Test("Favorite mutation decodes updated favorites array")
func favoriteMutationDecodesUpdatedFavoritesArray() throws {
let json = """
{
"error": false,
"result": {
"favorites": ["store_a"],
"store": {
"id": "store_a",
"name": "CPS Drinks",
"category": "Doces & Bolos",
"rating": 4.8,
"totalReviews": 12,
"isOpen": true,
"statusLabel": "Aberto"
}
}
}
"""
let envelope = try JSONDecoder().decode(ApiEnvelope<CustomerFavoritesMutationResult>.self, from: Data(json.utf8))
#expect(envelope.result?.favorites == ["store_a"])
#expect(envelope.result?.store?.id == "store_a")
#expect(envelope.result?.store?.name == "CPS Drinks")
}

View File

@@ -0,0 +1,104 @@
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)
}
}

View File

@@ -0,0 +1,46 @@
import Testing
@testable import PediFoods
@Test("FeatureFlagValue.boolValue passes through a boolean case directly")
func featureFlagValueBoolValuePassesThroughBoolean() {
#expect(FeatureFlagValue.boolean(true).boolValue == true)
#expect(FeatureFlagValue.boolean(false).boolValue == false)
}
@Test("FeatureFlagValue.boolValue treats 'on'/'true' text as true, case-insensitively")
func featureFlagValueBoolValueTextVariants() {
#expect(FeatureFlagValue.text("on").boolValue == true)
#expect(FeatureFlagValue.text("ON").boolValue == true)
#expect(FeatureFlagValue.text("true").boolValue == true)
#expect(FeatureFlagValue.text("off").boolValue == false)
#expect(FeatureFlagValue.text("something-else").boolValue == false)
}
@Test("FeatureFlagsState.isEnabled prefers the raw flag over the mapped value")
func featureFlagsStateIsEnabledPrefersRaw() {
var state = FeatureFlagsState()
state.raw["promo"] = FeatureControlRawFlag(enabled: true, variant: "on", payload: nil, reason: nil)
state.values["promo"] = .boolean(false)
#expect(state.isEnabled("promo") == true)
}
@Test("FeatureFlagsState.isEnabled treats a raw flag as enabled when its variant is 'on' even if enabled is false")
func featureFlagsStateIsEnabledRawVariantOn() {
var state = FeatureFlagsState()
state.raw["promo"] = FeatureControlRawFlag(enabled: false, variant: "on", payload: nil, reason: nil)
#expect(state.isEnabled("promo") == true)
}
@Test("FeatureFlagsState.isEnabled falls back to the mapped value when no raw flag exists")
func featureFlagsStateIsEnabledFallsBackToValues() {
var state = FeatureFlagsState()
state.values["promo"] = .text("on")
#expect(state.isEnabled("promo") == true)
}
@Test("FeatureFlagsState.isEnabled falls back to the given default when the key is unknown")
func featureFlagsStateIsEnabledFallsBackToDefault() {
let state = FeatureFlagsState()
#expect(state.isEnabled("unknown-key") == false)
#expect(state.isEnabled("unknown-key", default: true) == true)
}

View File

@@ -0,0 +1,47 @@
import Foundation
import Testing
@testable import PediFoods
/// `GuestLocationStore.shared` is a singleton backed by the real Keychain
/// under fixed key names - concurrent tests touching the same keys would
/// stomp on each other, so this suite runs serialized. Every test cleans
/// up after itself via the store's own clear/nil-set APIs.
@Suite(.serialized)
struct GuestLocationStoreTests {
@Test("deviceId is stable across repeated reads")
func deviceIdIsStableAcrossReads() {
let first = GuestLocationStore.shared.deviceId
let second = GuestLocationStore.shared.deviceId
#expect(first == second)
#expect(first.isEmpty == false)
}
@Test("selectedState/selectedCity round-trip and clear together via clearSelectedLocation")
func selectedStateAndCityRoundTripAndClear() {
GuestLocationStore.shared.selectedState = "SP"
GuestLocationStore.shared.selectedCity = "São Paulo"
#expect(GuestLocationStore.shared.selectedState == "SP")
#expect(GuestLocationStore.shared.selectedCity == "São Paulo")
GuestLocationStore.shared.clearSelectedLocation()
#expect(GuestLocationStore.shared.selectedState == nil)
#expect(GuestLocationStore.shared.selectedCity == nil)
}
@Test("appAttestKeyId round-trips and clears when set to nil")
func appAttestKeyIdRoundTripAndClear() {
GuestLocationStore.shared.appAttestKeyId = "key-123"
#expect(GuestLocationStore.shared.appAttestKeyId == "key-123")
GuestLocationStore.shared.appAttestKeyId = nil
#expect(GuestLocationStore.shared.appAttestKeyId == nil)
}
@Test("attestationPlaceholder is stable across repeated reads")
func attestationPlaceholderIsStableAcrossReads() {
let first = GuestLocationStore.shared.attestationPlaceholder
let second = GuestLocationStore.shared.attestationPlaceholder
#expect(first == second)
#expect(first.isEmpty == false)
}
}

View File

@@ -0,0 +1,23 @@
import Testing
@testable import PediFoods
@Test("reset restores every filter to its default except availableCategories")
func homeFiltersStateResetRestoresDefaults() {
var state = HomeFiltersState()
state.sortOption = .price
state.selectedCategories = ["pizza", "burger"]
state.selectedPriceTier = .high
state.maxDistanceKm = 25
state.availableCategories = ["pizza", "burger", "sushi"]
state.reset()
#expect(state.sortOption == .relevance)
#expect(state.selectedCategories.isEmpty)
#expect(state.selectedPriceTier == nil)
#expect(state.maxDistanceKm == 10)
// availableCategories reflects what the backend returned for this
// location, not a user selection - reset() intentionally leaves it
// alone so the filter sheet doesn't lose its option list.
#expect(state.availableCategories == ["pizza", "burger", "sushi"])
}

View File

@@ -0,0 +1,67 @@
import SwiftUI
import Testing
@testable import PediFoods
/// Constructs a `HomeView` directly (no host window/hierarchy needed to
/// call its plain funcs that take explicit parameters). Note:
/// `@State`-backed properties (`stores`, etc.) mutated *after*
/// construction do NOT reliably persist outside a real SwiftUI render
/// pass - confirmed empirically (computed properties reading `stores`
/// saw the untouched `[]` default even after `view.stores = ...`). So
/// this only covers `HomeView+Filtering` functions that take their
/// input as parameters, not ones that read `@State` implicitly -
/// `filteredStores` and friends are UI-test territory (Batch F), not
/// unit-test territory.
@MainActor
private func makeHomeView() -> HomeView {
var state = AppState()
let appStateBinding = Binding(get: { state }, set: { state = $0 })
let tabBinding = Binding<MainTab>(get: { .home }, set: { _ in })
return HomeView(appState: appStateBinding, selectedTab: tabBinding)
}
@Test("normalizeSearch folds diacritics and case")
@MainActor
func normalizeSearchFoldsDiacriticsAndCase() {
let view = makeHomeView()
#expect(view.normalizeSearch("Açaí") == "acai")
#expect(view.normalizeSearch("PIZZA") == "pizza")
#expect(view.normalizeSearch(" Café ") == "cafe")
}
@Test("matchesPriceTier buckets delivery fees at the documented boundaries")
@MainActor
func matchesPriceTierBoundaries() {
let view = makeHomeView()
#expect(view.matchesPriceTier(fee: 5, tier: .low))
#expect(view.matchesPriceTier(fee: 5.01, tier: .low) == false)
#expect(view.matchesPriceTier(fee: 5.01, tier: .medium))
#expect(view.matchesPriceTier(fee: 10, tier: .medium))
#expect(view.matchesPriceTier(fee: 10.01, tier: .high))
#expect(view.matchesPriceTier(fee: 20.01, tier: .veryHigh))
}
@Test("estimatedDeliveryMinutes picks the smaller number out of a range like '30-45 min'")
@MainActor
func estimatedDeliveryMinutesPicksMinimumOfRange() {
let view = makeHomeView()
#expect(view.estimatedDeliveryMinutes("30-45 min") == 30)
}
@Test("estimatedDeliveryMinutes returns Int.max for nil or non-numeric input")
@MainActor
func estimatedDeliveryMinutesMaxForMissingOrGarbageInput() {
let view = makeHomeView()
#expect(view.estimatedDeliveryMinutes(nil) == Int.max)
#expect(view.estimatedDeliveryMinutes("indisponível") == Int.max)
}
@Test("formatDistance shows meters below 1km and kilometers at or above 1km")
@MainActor
func formatDistanceSwitchesUnitsAtOneKm() {
let view = makeHomeView()
#expect(view.formatDistance(0.5) == "500 m")
#expect(view.formatDistance(1.0) == "1.0 km")
#expect(view.formatDistance(2.3) == "2.3 km")
#expect(view.formatDistance(nil) == "Distância indisponível")
}

View File

@@ -0,0 +1,46 @@
import Foundation
import Testing
@testable import PediFoods
@Test("ImageSourceResolver returns nil for nil, empty, or whitespace-only input")
func imageSourceResolverNilOnEmptyInput() {
#expect(ImageSourceResolver.resolve(nil) == nil)
#expect(ImageSourceResolver.resolve("") == nil)
#expect(ImageSourceResolver.resolve(" ") == nil)
}
@Test("ImageSourceResolver passes absolute http/https URLs through unchanged")
func imageSourceResolverPassesAbsoluteURLsThrough() {
#expect(ImageSourceResolver.resolve("https://cdn.example.com/img.png") == "https://cdn.example.com/img.png")
#expect(ImageSourceResolver.resolve("http://cdn.example.com/img.png") == "http://cdn.example.com/img.png")
}
@Test("ImageSourceResolver treats the http/https scheme check case-insensitively")
func imageSourceResolverSchemeCheckIsCaseInsensitive() {
let resolved = ImageSourceResolver.resolve("HTTPS://cdn.example.com/img.png")
#expect(resolved == "HTTPS://cdn.example.com/img.png")
}
@Test("ImageSourceResolver passes data:image URLs through unchanged")
func imageSourceResolverPassesDataImageURLsThrough() {
let dataURL = "data:image/png;base64,iVBORw0KGgo="
#expect(ImageSourceResolver.resolve(dataURL) == dataURL)
}
@Test("ImageSourceResolver normalizes JSON-escaped backslash-slashes before checking the scheme")
func imageSourceResolverNormalizesEscapedSlashes() {
let escaped = "https:\\/\\/cdn.example.com\\/img.png"
#expect(ImageSourceResolver.resolve(escaped) == "https://cdn.example.com/img.png")
}
@Test("ImageSourceResolver prefixes a relative path with the API base URL")
func imageSourceResolverPrefixesRelativePathWithBaseURL() {
let resolved = ImageSourceResolver.resolve("uploads/img.png")
#expect(resolved == "\(ApiConfig.baseURL.absoluteString)/uploads/img.png")
}
@Test("ImageSourceResolver doesn't double the leading slash for an already-rooted relative path")
func imageSourceResolverDoesNotDoubleLeadingSlash() {
let resolved = ImageSourceResolver.resolve("/uploads/img.png")
#expect(resolved == "\(ApiConfig.baseURL.absoluteString)/uploads/img.png")
}

View File

@@ -0,0 +1,12 @@
import Testing
@testable import PediFoods
@Test("Terms URL points to PediFoods customer terms endpoint")
func termsURL() {
#expect(LegalDocument.terms.url.absoluteString == "https://atomenta.com.br/api/public/pedi-foods-customer/terms")
}
@Test("Privacy policy URL points to PediFoods customer privacy-policy endpoint")
func privacyPolicyURL() {
#expect(LegalDocument.privacyPolicy.url.absoluteString == "https://atomenta.com.br/api/public/pedi-foods-customer/privacy-policy")
}

View File

@@ -0,0 +1,196 @@
import Foundation
import Testing
@testable import PediFoods
@Test("makeUserKey prefers a non-empty profileId over email")
func makeUserKeyPrefersProfileId() {
let key = SessionStateStore.makeUserKey(profileId: "abc123", email: "user@example.com")
#expect(key == "id:abc123")
}
@Test("makeUserKey falls back to email when profileId is nil or blank")
func makeUserKeyFallsBackToEmail() {
#expect(SessionStateStore.makeUserKey(profileId: nil, email: "User@Example.com") == "email:user@example.com")
#expect(SessionStateStore.makeUserKey(profileId: " ", email: "user@example.com") == "email:user@example.com")
}
@Test("makeUserKey returns nil when both profileId and email are absent")
func makeUserKeyNilWhenBothAbsent() {
#expect(SessionStateStore.makeUserKey(profileId: nil, email: nil) == nil)
#expect(SessionStateStore.makeUserKey(profileId: " ", email: " ") == nil)
}
/// `SessionStateStore.defaults` is one shared static var - Swift Testing
/// runs `@Test`s concurrently by default, so any test that swaps it would
/// race every other test in this file. `.serialized` forces this suite's
/// tests to run one at a time instead.
@Suite(.serialized)
struct SessionStateStorePersistenceTests {
/// Points `SessionStateStore` at a throwaway, uniquely-named
/// `UserDefaults` suite for the duration of one test, and restores
/// `.standard` after. Internal (not private) so the cross-file
/// extension in CartStateTests.swift can reuse it both need to be
/// in this same serialization domain, see that file's comment.
func withIsolatedDefaults(_ body: () throws -> Void) rethrows {
let suiteName = "SessionStateStoreTests.\(UUID().uuidString)"
let suite = UserDefaults(suiteName: suiteName)!
let previous = SessionStateStore.defaults
SessionStateStore.defaults = suite
defer {
SessionStateStore.defaults = previous
suite.removePersistentDomain(forName: suiteName)
}
try body()
}
@Test("shouldPromptPushOptIn is true before the first prompt is ever recorded")
func shouldPromptPushOptInTrueInitially() {
withIsolatedDefaults {
#expect(SessionStateStore.shouldPromptPushOptIn())
}
}
@Test("shouldPromptPushOptIn is false immediately after recording a prompt")
func shouldPromptPushOptInFalseRightAfterRecording() {
withIsolatedDefaults {
SessionStateStore.recordPushOptInPrompted()
#expect(SessionStateStore.shouldPromptPushOptIn() == false)
}
}
@Test("saveAddress/loadAddress round-trips every field")
func addressRoundTrip() {
withIsolatedDefaults {
let state = AddressState(selectedId: "addr-1", display: "Rua A, 100", latitude: -23.5, longitude: -46.6)
SessionStateStore.saveAddress(state)
let loaded = SessionStateStore.loadAddress()
#expect(loaded?.selectedId == "addr-1")
#expect(loaded?.display == "Rua A, 100")
#expect(loaded?.latitude == -23.5)
}
}
@Test("loadAddress substitutes a placeholder display string for an empty one")
func addressLoadReplacesEmptyDisplay() {
withIsolatedDefaults {
SessionStateStore.saveAddress(AddressState(selectedId: nil, display: ""))
#expect(SessionStateStore.loadAddress()?.display == "Defina seu endereco")
}
}
@Test("clearAddress removes a previously saved address")
func addressClear() {
withIsolatedDefaults {
SessionStateStore.saveAddress(AddressState(selectedId: "addr-1", display: "Rua A"))
SessionStateStore.clearAddress()
#expect(SessionStateStore.loadAddress() == nil)
}
}
@Test("saveCart/loadCart round-trips items, addons, and total")
func cartRoundTrip() {
withIsolatedDefaults {
let item = CartItemState(
id: "item-1", productId: "p1", storeId: "s1", name: "Pizza",
addons: [CartItemAddonState(id: "a1", name: "Extra cheese", quantity: 1, unitPrice: 3.0)],
quantity: 2, unitPrice: 29.9
)
let cart = CartState(storeId: "s1", storeName: "Loja A", items: [item], total: 65.8)
SessionStateStore.saveCart(cart)
let loaded = SessionStateStore.loadCart()
#expect(loaded?.storeId == "s1")
#expect(loaded?.items.first?.name == "Pizza")
#expect(loaded?.items.first?.addons.first?.name == "Extra cheese")
#expect(loaded?.total == 65.8)
}
}
@Test("clearCart removes a previously saved cart")
func cartClear() {
withIsolatedDefaults {
SessionStateStore.saveCart(CartState(storeId: "s1", storeName: "Loja A", items: [], total: 0))
SessionStateStore.clearCart()
#expect(SessionStateStore.loadCart() == nil)
}
}
@Test("saveTrackedOrder inserts new orders at the front and updates existing ones in place")
func trackedOrdersInsertAndUpdate() {
withIsolatedDefaults {
SessionStateStore.saveTrackedOrder(PublicOrderResult(id: "o1", status: "PENDING"))
SessionStateStore.saveTrackedOrder(PublicOrderResult(id: "o2", status: "PENDING"))
var orders = SessionStateStore.loadTrackedOrders()
#expect(orders.map(\.id) == ["o2", "o1"])
SessionStateStore.saveTrackedOrder(PublicOrderResult(id: "o1", status: "COMPLETED"))
orders = SessionStateStore.loadTrackedOrders()
#expect(orders.map(\.id) == ["o2", "o1"])
#expect(orders.first(where: { $0.id == "o1" })?.status == "COMPLETED")
}
}
@Test("loadTrackedOrder finds a specific order by id")
func trackedOrderLookupById() {
withIsolatedDefaults {
SessionStateStore.saveTrackedOrder(PublicOrderResult(id: "o1", status: "PENDING"))
#expect(SessionStateStore.loadTrackedOrder(orderId: "o1")?.status == "PENDING")
#expect(SessionStateStore.loadTrackedOrder(orderId: "missing") == nil)
}
}
@Test("savePendingCartOrderId clears the value when given an empty string")
func pendingCartOrderIdClearsOnEmptyString() {
withIsolatedDefaults {
SessionStateStore.savePendingCartOrderId("order-1")
#expect(SessionStateStore.loadPendingCartOrderId() == "order-1")
SessionStateStore.savePendingCartOrderId(" ")
#expect(SessionStateStore.loadPendingCartOrderId() == nil)
}
}
@Test("saveOrderReview de-duplicates by orderId case- and whitespace-insensitively")
func orderReviewDedupesByOrderId() {
withIsolatedDefaults {
let review1 = OrderReviewRecord(
orderId: "Order-1", storeId: nil, shortId: nil, storeName: nil, storeLogoURL: nil,
createdAt: nil, submittedAt: "2026-01-01", rating: 4, comment: "Good",
orderPositiveTags: nil, orderImprovementTags: nil, deliverySentiment: nil,
deliveryPositiveTags: nil, deliveryNegativeTags: nil, appNps: nil, platform: nil,
editableUntil: nil, storeReplyUntil: nil, reviewWindowExpiresAt: nil,
storeReplyMessage: nil, storeReplyAt: nil
)
SessionStateStore.saveOrderReview(review1)
#expect(SessionStateStore.hasOrderReview(orderId: "order-1"))
#expect(SessionStateStore.loadOrderReviews().count == 1)
let review2 = OrderReviewRecord(
orderId: "order-1 ", storeId: nil, shortId: nil, storeName: nil, storeLogoURL: nil,
createdAt: nil, submittedAt: "2026-01-02", rating: 5, comment: "Even better",
orderPositiveTags: nil, orderImprovementTags: nil, deliverySentiment: nil,
deliveryPositiveTags: nil, deliveryNegativeTags: nil, appNps: nil, platform: nil,
editableUntil: nil, storeReplyUntil: nil, reviewWindowExpiresAt: nil,
storeReplyMessage: nil, storeReplyAt: nil
)
SessionStateStore.saveOrderReview(review2)
#expect(SessionStateStore.loadOrderReviews().count == 1)
#expect(SessionStateStore.loadOrderReviews().first?.rating == 5)
}
}
@Test("saveOrderReviewDraft/loadOrderReviewDraft round-trips and clearOrderReviewDraft removes it")
func orderReviewDraftRoundTripAndClear() {
withIsolatedDefaults {
let draft = OrderReviewDraftState(
orderId: "o1", orderRate: 5, orderComment: "Great", orderPositiveTags: ["fast"],
orderImprovementTags: [], deliverySentiment: "good", deliveryPositiveTags: [],
deliveryNegativeTags: [], appNps: 9, platform: "ios"
)
SessionStateStore.saveOrderReviewDraft(draft)
#expect(SessionStateStore.loadOrderReviewDraft(orderId: "o1")?.orderComment == "Great")
SessionStateStore.clearOrderReviewDraft(orderId: "o1")
#expect(SessionStateStore.loadOrderReviewDraft(orderId: "o1") == nil)
}
}
}

View File

@@ -0,0 +1,7 @@
import Testing
@testable import PediFoods
@Test("Smoke")
func smoke() {
#expect(Bool(true))
}

View File

@@ -0,0 +1,65 @@
import Testing
@testable import PediFoods
@Test("show sets current with the given title, style, and persistence flag")
@MainActor
func showSetsCurrentMessage() {
let center = SnackbarCenter()
center.show(title: "Saved!", style: .success, isPersistent: false)
#expect(center.current?.title == "Saved!")
#expect(center.current?.style == .success)
#expect(center.current?.isPersistent == false)
}
@Test("handleTap dismisses a non-persistent message and runs its action")
@MainActor
func handleTapDismissesAndRunsAction() {
let center = SnackbarCenter()
var actionRan = false
center.show(title: "Undo?", isPersistent: false, action: { actionRan = true })
center.handleTap()
#expect(center.current == nil)
#expect(actionRan)
}
@Test("handleTap does nothing for a persistent message")
@MainActor
func handleTapIgnoresPersistentMessage() {
let center = SnackbarCenter()
var actionRan = false
center.show(title: "Uploading…", isPersistent: true, action: { actionRan = true })
center.handleTap()
#expect(center.current != nil)
#expect(actionRan == false)
}
@Test("dismiss(animated:) clears the current message unconditionally")
@MainActor
func dismissClearsCurrentMessage() {
let center = SnackbarCenter()
center.show(title: "Hello", isPersistent: true)
center.dismiss(animated: false)
#expect(center.current == nil)
}
@Test("dismissPersistent only clears the message when it's actually persistent")
@MainActor
func dismissPersistentOnlyClearsPersistentMessages() {
let center = SnackbarCenter()
center.show(title: "Transient", isPersistent: false)
center.dismissPersistent()
#expect(center.current != nil)
center.show(title: "Persistent", isPersistent: true)
center.dismissPersistent()
#expect(center.current == nil)
}
@Test("show replaces a currently-displayed message with the new one")
@MainActor
func showReplacesCurrentMessage() {
let center = SnackbarCenter()
center.show(title: "First", isPersistent: true)
center.show(title: "Second", isPersistent: true)
#expect(center.current?.title == "Second")
}

View File

@@ -0,0 +1,117 @@
import Testing
@testable import PediFoods
@Test("Store catalog normalizer makes IDs non-empty and unique")
func storeCatalogNormalizerMakesIdsUnique() {
let catalog = [
StoreCatalogCategory(
id: "",
name: "Pizzas",
isPizzaCategory: true,
pizzaConfig: StorePizzaConfig(
sizes: [
StorePizzaSize(id: "", name: "Grande", slices: 8, maxFlavors: 2),
StorePizzaSize(id: "", name: "Familia", slices: 12, maxFlavors: 3)
],
doughs: [
StorePizzaDough(id: "massa", name: "Tradicional", active: true),
StorePizzaDough(id: "massa", name: "Fina", active: true)
],
crusts: [
StorePizzaCrust(id: "", name: "Cheddar", active: true, priceModifier: 5),
StorePizzaCrust(id: "", name: "Catupiry", active: true, priceModifier: 6)
]
),
products: [
StoreCatalogProduct(
id: "",
type: "pizza",
name: "Calabresa",
description: nil,
image: nil,
price: 10,
originalPrice: nil,
pizzaPrices: [:],
addonGroups: [
StoreAddonGroup(
id: "",
name: "Extras",
minSelectors: nil,
maxSelectors: nil,
items: [
StoreAddonItem(id: "", name: "Bacon", price: 2),
StoreAddonItem(id: "", name: "Bacon em dobro", price: 4)
]
),
StoreAddonGroup(
id: "",
name: "Molhos",
minSelectors: nil,
maxSelectors: nil,
items: [
StoreAddonItem(id: "", name: "Alho", price: 1)
]
)
]
),
StoreCatalogProduct(
id: "",
type: "pizza",
name: "Mussarela",
description: nil,
image: nil,
price: 12,
originalPrice: nil,
pizzaPrices: [:],
addonGroups: []
)
]
),
StoreCatalogCategory(
id: "",
name: "Bebidas",
isPizzaCategory: false,
pizzaConfig: nil,
products: [
StoreCatalogProduct(
id: "",
type: nil,
name: "Refrigerante",
description: nil,
image: nil,
price: 7,
originalPrice: nil,
pizzaPrices: [:],
addonGroups: []
)
]
)
]
let normalized = StoreCatalogNormalizer.sanitize(categories: catalog, storeId: "store-1")
let categoryIds = normalized.map(\.id)
#expect(Set(categoryIds).count == categoryIds.count)
#expect(categoryIds.allSatisfy { $0.isEmpty == false })
let firstCategory = normalized[0]
let productIds = firstCategory.products.map(\.id)
#expect(Set(productIds).count == productIds.count)
#expect(productIds.allSatisfy { $0.isEmpty == false })
let addonGroupIds = firstCategory.products[0].addonGroups.map(\.id)
#expect(Set(addonGroupIds).count == addonGroupIds.count)
#expect(addonGroupIds.allSatisfy { $0.isEmpty == false })
let addonItemIds = firstCategory.products[0].addonGroups.flatMap(\.items).map(\.id)
#expect(Set(addonItemIds).count == addonItemIds.count)
#expect(addonItemIds.allSatisfy { $0.isEmpty == false })
let sizeIds = firstCategory.pizzaConfig?.sizes.map(\.id) ?? []
let doughIds = firstCategory.pizzaConfig?.doughs.map(\.id) ?? []
let crustIds = firstCategory.pizzaConfig?.crusts.map(\.id) ?? []
#expect(Set(sizeIds).count == sizeIds.count)
#expect(Set(doughIds).count == doughIds.count)
#expect(Set(crustIds).count == crustIds.count)
}

View File

@@ -0,0 +1,46 @@
import Foundation
/// Intercepts every request made through a `URLSession` configured with it,
/// so network-dependent code (`ApiClient`, `ApiService`,
/// `FeatureControlService`, ...) can be unit tested without touching a real
/// server. Register a handler per test, then build a session via
/// `URLProtocolStub.makeSession()`.
final class URLProtocolStub: URLProtocol {
/// `nonisolated(unsafe)`: `URLProtocol` subclasses are instantiated and
/// driven by URLSession's own internal (non-Sendable-checked) machinery,
/// off the calling actor. Tests only ever set this once, synchronously,
/// before starting the request that reads it.
nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))?
static func makeSession() -> URLSession {
let config = URLSessionConfiguration.ephemeral
config.protocolClasses = [URLProtocolStub.self]
return URLSession(configuration: config)
}
override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() {
guard let handler = URLProtocolStub.handler else {
client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse))
return
}
do {
let (response, data) = try handler(request)
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: data)
client?.urlProtocolDidFinishLoading(self)
} catch {
client?.urlProtocol(self, didFailWithError: error)
}
}
override func stopLoading() {}
}
extension HTTPURLResponse {
static func stub(url: URL = URL(string: "https://example.com")!, statusCode: Int) -> HTTPURLResponse {
HTTPURLResponse(url: url, statusCode: statusCode, httpVersion: nil, headerFields: nil)!
}
}